fix: 加固 LinuxDo OAuth 登录安全与配置校验
This commit is contained in:
@@ -324,10 +324,10 @@ type TurnstileConfig struct {
|
|||||||
Required bool `mapstructure:"required"`
|
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).
|
// 注意:这与上游账号的 OAuth(例如 OpenAI/Gemini 账号接入)不是一回事。
|
||||||
// It is used for logging in to Sub2API itself.
|
// 这里是用于登录 Sub2API 本身的用户体系。
|
||||||
type LinuxDoConnectConfig struct {
|
type LinuxDoConnectConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
ClientID string `mapstructure:"client_id"`
|
ClientID string `mapstructure:"client_id"`
|
||||||
@@ -336,13 +336,13 @@ type LinuxDoConnectConfig struct {
|
|||||||
TokenURL string `mapstructure:"token_url"`
|
TokenURL string `mapstructure:"token_url"`
|
||||||
UserInfoURL string `mapstructure:"userinfo_url"`
|
UserInfoURL string `mapstructure:"userinfo_url"`
|
||||||
Scopes string `mapstructure:"scopes"`
|
Scopes string `mapstructure:"scopes"`
|
||||||
RedirectURL string `mapstructure:"redirect_url"` // backend callback URL registered at the provider
|
RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记)
|
||||||
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // frontend route to receive token (default: /auth/linuxdo/callback)
|
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/linuxdo/callback)
|
||||||
TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none
|
TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none
|
||||||
UsePKCE bool `mapstructure:"use_pkce"`
|
UsePKCE bool `mapstructure:"use_pkce"`
|
||||||
|
|
||||||
// Optional: gjson paths to extract fields from userinfo JSON.
|
// 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。
|
||||||
// When empty, the server tries a set of common keys.
|
// 为空时,服务端会尝试一组常见字段名。
|
||||||
UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
|
UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
|
||||||
UserInfoIDPath string `mapstructure:"userinfo_id_path"`
|
UserInfoIDPath string `mapstructure:"userinfo_id_path"`
|
||||||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
||||||
@@ -464,7 +464,8 @@ func Load() (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAbsoluteHTTPURL(raw string) error {
|
// ValidateAbsoluteHTTPURL 校验一个绝对 http(s) URL(禁止 fragment)。
|
||||||
|
func ValidateAbsoluteHTTPURL(raw string) error {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return fmt.Errorf("empty url")
|
return fmt.Errorf("empty url")
|
||||||
@@ -488,7 +489,10 @@ func validateAbsoluteHTTPURL(raw string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateFrontendRedirectURL(raw string) error {
|
// ValidateFrontendRedirectURL 校验前端回调地址:
|
||||||
|
// - 允许同源相对路径(以 / 开头)
|
||||||
|
// - 或绝对 http(s) URL(禁止 fragment)
|
||||||
|
func ValidateFrontendRedirectURL(raw string) error {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return fmt.Errorf("empty url")
|
return fmt.Errorf("empty url")
|
||||||
@@ -584,7 +588,7 @@ func setDefaults() {
|
|||||||
// Turnstile
|
// Turnstile
|
||||||
viper.SetDefault("turnstile.required", false)
|
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.enabled", false)
|
||||||
viper.SetDefault("linuxdo_connect.client_id", "")
|
viper.SetDefault("linuxdo_connect.client_id", "")
|
||||||
viper.SetDefault("linuxdo_connect.client_secret", "")
|
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")
|
return fmt.Errorf("linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil {
|
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil {
|
||||||
return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err)
|
return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil {
|
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil {
|
||||||
return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err)
|
return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil {
|
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil {
|
||||||
return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err)
|
return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil {
|
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil {
|
||||||
return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err)
|
return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil {
|
if err := ValidateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil {
|
||||||
return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err)
|
return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
@@ -94,7 +94,7 @@ type UpdateSettingsRequest struct {
|
|||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||||||
|
|
||||||
// LinuxDo Connect OAuth login (end-user SSO)
|
// LinuxDo Connect OAuth 登录(终端用户 SSO)
|
||||||
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||||||
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||||||
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
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")
|
response.BadRequest(c, "LinuxDo Redirect URL is required when enabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL) {
|
if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil {
|
||||||
response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL")
|
response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If client_secret not provided, keep existing value (if any).
|
// 如果未提供 client_secret,则保留现有值(如有)。
|
||||||
if req.LinuxDoConnectClientSecret == "" {
|
if req.LinuxDoConnectClientSecret == "" {
|
||||||
if previousSettings.LinuxDoConnectClientSecret == "" {
|
if previousSettings.LinuxDoConnectClientSecret == "" {
|
||||||
response.BadRequest(c, "LinuxDo Client Secret is required when enabled")
|
response.BadRequest(c, "LinuxDo Client Secret is required when enabled")
|
||||||
@@ -407,33 +407,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
return changed
|
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连接请求
|
// TestSMTPRequest 测试SMTP连接请求
|
||||||
type TestSMTPRequest struct {
|
type TestSMTPRequest struct {
|
||||||
SMTPHost string `json:"smtp_host" binding:"required"`
|
SMTPHost string `json:"smtp_host" binding:"required"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
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"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/imroc/req/v3"
|
"github.com/imroc/req/v3"
|
||||||
@@ -66,7 +67,7 @@ func (e *linuxDoTokenExchangeError) Error() string {
|
|||||||
return strings.Join(parts, " ")
|
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
|
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
|
||||||
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
||||||
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||||
@@ -116,7 +117,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, authURL)
|
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=...
|
// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
|
||||||
func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
||||||
cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
|
cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||||
@@ -197,16 +198,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email, username, _, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp)
|
email, username, subject, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
|
||||||
|
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
|
||||||
|
if subject != "" {
|
||||||
|
email = linuxDoSyntheticEmail(subject)
|
||||||
|
}
|
||||||
|
|
||||||
jwtToken, _, err := h.authService.LoginOrRegisterOAuth(c.Request.Context(), email, username)
|
jwtToken, _, err := h.authService.LoginOrRegisterOAuth(c.Request.Context(), email, username)
|
||||||
if err != nil {
|
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))
|
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -352,9 +359,8 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
|
|||||||
|
|
||||||
email = strings.TrimSpace(email)
|
email = strings.TrimSpace(email)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
// LinuxDo Connect userinfo does not necessarily provide email. To keep compatibility with the
|
// LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。
|
||||||
// existing user schema (email is required/unique), use a stable synthetic email.
|
email = linuxDoSyntheticEmail(subject)
|
||||||
email = fmt.Sprintf("linuxdo-%s@linuxdo-connect.invalid", subject)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
username = strings.TrimSpace(username)
|
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) {
|
func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) {
|
||||||
u, err := url.Parse(frontendCallback)
|
u, err := url.Parse(frontendCallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback: best-effort redirect.
|
// 兜底:尽力跳转到默认页面,避免卡死在回调页。
|
||||||
c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
|
c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -545,7 +551,7 @@ func sanitizeFrontendRedirectPath(path string) string {
|
|||||||
if len(path) > linuxDoOAuthMaxRedirectLen {
|
if len(path) > linuxDoOAuthMaxRedirectLen {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Only allow same-origin relative paths (avoid open redirect).
|
// 只允许同源相对路径(避免开放重定向)。
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -663,3 +669,11 @@ func isSafeLinuxDoSubject(subject string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func linuxDoSyntheticEmail(subject string) string {
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
if subject == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ var (
|
|||||||
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
||||||
)
|
)
|
||||||
|
|
||||||
const linuxDoSyntheticEmailDomain = "@linuxdo-connect.invalid"
|
|
||||||
|
|
||||||
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
||||||
const maxTokenLength = 8192
|
const maxTokenLength = 8192
|
||||||
|
|
||||||
@@ -87,7 +85,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
|||||||
return "", nil, ErrRegDisabled
|
return "", nil, ErrRegDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent users from registering emails reserved for synthetic OAuth accounts.
|
// 防止用户注册 LinuxDo OAuth 合成邮箱,避免第三方登录与本地账号发生碰撞。
|
||||||
if isReservedEmail(email) {
|
if isReservedEmail(email) {
|
||||||
return "", nil, ErrEmailReserved
|
return "", nil, ErrEmailReserved
|
||||||
}
|
}
|
||||||
@@ -339,11 +337,12 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
|
|||||||
return token, user, nil
|
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
|
// 注意:该函数用于“终端用户登录 Sub2API 本身”的场景(不同于上游账号的 OAuth,例如 OpenAI/Gemini)。
|
||||||
// NOT require the local password. A random password hash is generated for new users to satisfy
|
// 为了满足现有数据库约束(需要密码哈希),新用户会生成随机密码并进行哈希保存。
|
||||||
// the existing database constraint.
|
|
||||||
func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) {
|
func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) {
|
||||||
email = strings.TrimSpace(email)
|
email = strings.TrimSpace(email)
|
||||||
if email == "" || len(email) > 255 {
|
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)
|
user, err := s.userRepo.GetByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrUserNotFound) {
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
// Treat OAuth-first login as registration.
|
// OAuth 首次登录视为注册。
|
||||||
if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) {
|
if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) {
|
||||||
return "", nil, ErrRegDisabled
|
return "", nil, ErrRegDisabled
|
||||||
}
|
}
|
||||||
@@ -376,7 +375,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
|||||||
return "", nil, fmt.Errorf("hash password: %w", err)
|
return "", nil, fmt.Errorf("hash password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults for new users.
|
// 新用户默认值。
|
||||||
defaultBalance := s.cfg.Default.UserBalance
|
defaultBalance := s.cfg.Default.UserBalance
|
||||||
defaultConcurrency := s.cfg.Default.UserConcurrency
|
defaultConcurrency := s.cfg.Default.UserConcurrency
|
||||||
if s.settingService != nil {
|
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 err := s.userRepo.Create(ctx, newUser); err != nil {
|
||||||
if errors.Is(err, ErrEmailExists) {
|
if errors.Is(err, ErrEmailExists) {
|
||||||
// Race: user created between GetByEmail and Create.
|
// 并发场景:GetByEmail 与 Create 之间用户被创建。
|
||||||
user, err = s.userRepo.GetByEmail(ctx, email)
|
user, err = s.userRepo.GetByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Auth] Database error getting user after conflict: %v", err)
|
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
|
return "", nil, ErrUserNotActive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort: fill username when empty.
|
// 尽力补全:当用户名为空时,使用第三方返回的用户名回填。
|
||||||
if user.Username == "" && username != "" {
|
if user.Username == "" && username != "" {
|
||||||
user.Username = username
|
user.Username = username
|
||||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
@@ -489,7 +488,7 @@ func randomHexString(byteLength int) (string, error) {
|
|||||||
|
|
||||||
func isReservedEmail(email string) bool {
|
func isReservedEmail(email string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||||
return strings.HasSuffix(normalized, linuxDoSyntheticEmailDomain)
|
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken 生成JWT token
|
// GenerateToken 生成JWT token
|
||||||
|
|||||||
@@ -106,12 +106,16 @@ const (
|
|||||||
SettingKeyEnableIdentityPatch = "enable_identity_patch"
|
SettingKeyEnableIdentityPatch = "enable_identity_patch"
|
||||||
SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
|
SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
|
||||||
|
|
||||||
// LinuxDo Connect OAuth login (end-user SSO)
|
// LinuxDo Connect OAuth 登录(终端用户 SSO)
|
||||||
SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled"
|
SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled"
|
||||||
SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id"
|
SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id"
|
||||||
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
|
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
|
||||||
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
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).
|
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||||
const AdminAPIKeyPrefix = "admin-"
|
const AdminAPIKeyPrefix = "admin-"
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinuxDo Connect OAuth login (end-user SSO)
|
// LinuxDo Connect OAuth 登录(终端用户 SSO)
|
||||||
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
||||||
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
||||||
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
||||||
@@ -289,9 +289,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
result.SMTPPassword = settings[SettingKeySMTPPassword]
|
||||||
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
|
||||||
|
|
||||||
// LinuxDo Connect settings:
|
// LinuxDo Connect 设置:
|
||||||
// - Backward compatible with config.yaml/env (so existing deployments don't get disabled by accident)
|
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
||||||
// - Can be overridden and persisted via admin "system settings" (stored in DB)
|
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB)
|
||||||
linuxDoBase := config.LinuxDoConnectConfig{}
|
linuxDoBase := config.LinuxDoConnectConfig{}
|
||||||
if s.cfg != nil {
|
if s.cfg != nil {
|
||||||
linuxDoBase = s.cfg.LinuxDo
|
linuxDoBase = s.cfg.LinuxDo
|
||||||
@@ -339,11 +339,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
return result
|
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.
|
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
|
||||||
// - Otherwise, it falls back to config.yaml/env values.
|
// - 否则回退到 config.yaml/env 的值
|
||||||
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
||||||
if s == nil || s.cfg == nil {
|
if s == nil || s.cfg == nil {
|
||||||
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
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")
|
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) == "" {
|
if strings.TrimSpace(effective.ClientID) == "" {
|
||||||
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
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")
|
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))
|
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
|
||||||
switch method {
|
switch method {
|
||||||
case "", "client_secret_post", "client_secret_basic":
|
case "", "client_secret_post", "client_secret_basic":
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type SystemSettings struct {
|
|||||||
TurnstileSecretKey string
|
TurnstileSecretKey string
|
||||||
TurnstileSecretKeyConfigured bool
|
TurnstileSecretKeyConfigured bool
|
||||||
|
|
||||||
// LinuxDo Connect OAuth login (end-user SSO)
|
// LinuxDo Connect OAuth 登录(终端用户 SSO)
|
||||||
LinuxDoConnectEnabled bool
|
LinuxDoConnectEnabled bool
|
||||||
LinuxDoConnectClientID string
|
LinuxDoConnectClientID string
|
||||||
LinuxDoConnectClientSecret string
|
LinuxDoConnectClientSecret string
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface SystemSettings {
|
|||||||
turnstile_enabled: boolean
|
turnstile_enabled: boolean
|
||||||
turnstile_site_key: string
|
turnstile_site_key: string
|
||||||
turnstile_secret_key_configured: boolean
|
turnstile_secret_key_configured: boolean
|
||||||
// LinuxDo Connect OAuth login (end-user SSO)
|
// LinuxDo Connect OAuth 登录(终端用户 SSO)
|
||||||
linuxdo_connect_enabled: boolean
|
linuxdo_connect_enabled: boolean
|
||||||
linuxdo_connect_client_id: string
|
linuxdo_connect_client_id: string
|
||||||
linuxdo_connect_client_secret_configured: boolean
|
linuxdo_connect_client_secret_configured: boolean
|
||||||
|
|||||||
61
frontend/src/components/auth/LinuxDoOAuthSection.vue
Normal file
61
frontend/src/components/auth/LinuxDoOAuthSection.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const contactInfo = ref<string>('')
|
const contactInfo = ref<string>('')
|
||||||
const apiBaseUrl = ref<string>('')
|
const apiBaseUrl = ref<string>('')
|
||||||
const docUrl = ref<string>('')
|
const docUrl = ref<string>('')
|
||||||
|
const cachedPublicSettings = ref<PublicSettings | null>(null)
|
||||||
|
|
||||||
// Version cache state
|
// Version cache state
|
||||||
const versionLoaded = ref<boolean>(false)
|
const versionLoaded = ref<boolean>(false)
|
||||||
@@ -282,24 +283,27 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
* Fetch public settings (uses cache unless force=true)
|
* Fetch public settings (uses cache unless force=true)
|
||||||
* @param force - Force refresh from API
|
* @param force - Force refresh from API
|
||||||
*/
|
*/
|
||||||
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
||||||
// Return cached data if available and not forcing refresh
|
// Return cached data if available and not forcing refresh
|
||||||
if (publicSettingsLoaded.value && !force) {
|
if (publicSettingsLoaded.value && !force) {
|
||||||
return {
|
if (cachedPublicSettings.value) {
|
||||||
registration_enabled: false,
|
return { ...cachedPublicSettings.value }
|
||||||
email_verify_enabled: false,
|
}
|
||||||
turnstile_enabled: false,
|
return {
|
||||||
turnstile_site_key: '',
|
registration_enabled: false,
|
||||||
site_name: siteName.value,
|
email_verify_enabled: false,
|
||||||
site_logo: siteLogo.value,
|
turnstile_enabled: false,
|
||||||
site_subtitle: '',
|
turnstile_site_key: '',
|
||||||
api_base_url: apiBaseUrl.value,
|
site_name: siteName.value,
|
||||||
contact_info: contactInfo.value,
|
site_logo: siteLogo.value,
|
||||||
doc_url: docUrl.value,
|
site_subtitle: '',
|
||||||
linuxdo_oauth_enabled: false,
|
api_base_url: apiBaseUrl.value,
|
||||||
version: siteVersion.value
|
contact_info: contactInfo.value,
|
||||||
}
|
doc_url: docUrl.value,
|
||||||
}
|
linuxdo_oauth_enabled: false,
|
||||||
|
version: siteVersion.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent duplicate requests
|
// Prevent duplicate requests
|
||||||
if (publicSettingsLoading.value) {
|
if (publicSettingsLoading.value) {
|
||||||
@@ -309,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
publicSettingsLoading.value = true
|
publicSettingsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await fetchPublicSettingsAPI()
|
const data = await fetchPublicSettingsAPI()
|
||||||
|
cachedPublicSettings.value = data
|
||||||
siteName.value = data.site_name || 'Sub2API'
|
siteName.value = data.site_name || 'Sub2API'
|
||||||
siteLogo.value = data.site_logo || ''
|
siteLogo.value = data.site_logo || ''
|
||||||
siteVersion.value = data.version || ''
|
siteVersion.value = data.version || ''
|
||||||
@@ -330,6 +335,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
*/
|
*/
|
||||||
function clearPublicSettingsCache(): void {
|
function clearPublicSettingsCache(): void {
|
||||||
publicSettingsLoaded.value = false
|
publicSettingsLoaded.value = false
|
||||||
|
cachedPublicSettings.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Return Store API ====================
|
// ==================== Return Store API ====================
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set token directly (OAuth/SSO callback) and load current user profile.
|
* 直接设置 token(用于 OAuth/SSO 回调),并加载当前用户信息。
|
||||||
* @param newToken - JWT access token issued by backend
|
* @param newToken - 后端签发的 JWT access token
|
||||||
*/
|
*/
|
||||||
async function setToken(newToken: string): Promise<User> {
|
async function setToken(newToken: string): Promise<User> {
|
||||||
// Clear any previous state first (avoid mixing sessions)
|
// Clear any previous state first (avoid mixing sessions)
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LinuxDo Connect OAuth Login -->
|
<!-- LinuxDo Connect OAuth 登录 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
<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">
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -850,7 +850,7 @@ const form = reactive<SettingsForm>({
|
|||||||
turnstile_site_key: '',
|
turnstile_site_key: '',
|
||||||
turnstile_secret_key: '',
|
turnstile_secret_key: '',
|
||||||
turnstile_secret_key_configured: false,
|
turnstile_secret_key_configured: false,
|
||||||
// LinuxDo Connect OAuth
|
// LinuxDo Connect OAuth(终端用户登录)
|
||||||
linuxdo_connect_enabled: false,
|
linuxdo_connect_enabled: false,
|
||||||
linuxdo_connect_client_id: '',
|
linuxdo_connect_client_id: '',
|
||||||
linuxdo_connect_client_secret: '',
|
linuxdo_connect_client_secret: '',
|
||||||
|
|||||||
@@ -11,50 +11,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LinuxDo Connect OAuth -->
|
<!-- LinuxDo Connect OAuth 登录 -->
|
||||||
<div v-if="linuxdoOAuthEnabled" class="space-y-4">
|
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||||
@@ -202,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -367,14 +326,6 @@ async function handleLogin(): Promise<void> {
|
|||||||
isLoading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,50 +11,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LinuxDo Connect OAuth -->
|
<!-- LinuxDo Connect OAuth 登录 -->
|
||||||
<div v-if="linuxdoOAuthEnabled" class="space-y-4">
|
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Registration Disabled Message -->
|
<!-- Registration Disabled Message -->
|
||||||
<div
|
<div
|
||||||
@@ -226,6 +184,7 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@@ -413,14 +372,6 @@ async function handleRegister(): Promise<void> {
|
|||||||
isLoading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user