diff --git a/Linux DO Connect.md b/Linux DO Connect.md
new file mode 100644
index 00000000..7ca1260f
--- /dev/null
+++ b/Linux DO Connect.md
@@ -0,0 +1,368 @@
+# Linux DO Connect
+
+OAuth(Open Authorization)是一个开放的网络授权标准,目前最新版本为 OAuth 2.0。我们日常使用的第三方登录(如 Google 账号登录)就采用了该标准。OAuth 允许用户授权第三方应用访问存储在其他服务提供商(如 Google)上的信息,无需在不同平台上重复填写注册信息。用户授权后,平台可以直接访问用户的账户信息进行身份验证,而用户无需向第三方应用提供密码。
+
+目前系统已实现完整的 OAuth2 授权码(code)方式鉴权,但界面等配套功能还在持续完善中。让我们一起打造一个更完善的共享方案。
+
+## 基本介绍
+
+这是一套标准的 OAuth2 鉴权系统,可以让开发者共享论坛的用户基本信息。
+
+- 可获取字段:
+
+| 参数 | 说明 |
+| ----------------- | ------------------------------- |
+| `id` | 用户唯一标识(不可变) |
+| `username` | 论坛用户名 |
+| `name` | 论坛用户昵称(可变) |
+| `avatar_template` | 用户头像模板URL(支持多种尺寸) |
+| `active` | 账号活跃状态 |
+| `trust_level` | 信任等级(0-4) |
+| `silenced` | 禁言状态 |
+| `external_ids` | 外部ID关联信息 |
+| `api_key` | API访问密钥 |
+
+通过这些信息,公益网站/接口可以实现:
+
+1. 基于 `id` 的服务频率限制
+2. 基于 `trust_level` 的服务额度分配
+3. 基于用户信息的滥用举报机制
+
+## 相关端点
+
+- Authorize 端点: `https://connect.linux.do/oauth2/authorize`
+- Token 端点:`https://connect.linux.do/oauth2/token`
+- 用户信息 端点:`https://connect.linux.do/api/user`
+
+## 申请使用
+
+- 访问 [Connect.Linux.Do](https://connect.linux.do/) 申请接入你的应用。
+
+
+
+- 点击 **`我的应用接入`** - **`申请新接入`**,填写相关信息。其中 **`回调地址`** 是你的应用接收用户信息的地址。
+
+
+
+- 申请成功后,你将获得 **`Client Id`** 和 **`Client Secret`**,这是你应用的唯一身份凭证。
+
+
+
+## 接入 Linux Do
+
+JavaScript
+```JavaScript
+// 安装第三方请求库(或使用原生的 Fetch API),本例中使用 axios
+// npm install axios
+
+// 通过 OAuth2 获取 Linux Do 用户信息的参考流程
+const axios = require('axios');
+const readline = require('readline');
+
+// 配置信息(建议通过环境变量配置,避免使用硬编码)
+const CLIENT_ID = '你的 Client ID';
+const CLIENT_SECRET = '你的 Client Secret';
+const REDIRECT_URI = '你的回调地址';
+const AUTH_URL = 'https://connect.linux.do/oauth2/authorize';
+const TOKEN_URL = 'https://connect.linux.do/oauth2/token';
+const USER_INFO_URL = 'https://connect.linux.do/api/user';
+
+// 第一步:生成授权 URL
+function getAuthUrl() {
+ const params = new URLSearchParams({
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ response_type: 'code',
+ scope: 'user'
+ });
+
+ return `${AUTH_URL}?${params.toString()}`;
+}
+
+// 第二步:获取 code 参数
+function getCode() {
+ return new Promise((resolve) => {
+ // 本例中使用终端输入来模拟流程,仅供本地测试
+ // 请在实际应用中替换为真实的处理逻辑
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+ rl.question('从回调 URL 中提取出 code,粘贴到此处并按回车:', (answer) => {
+ rl.close();
+ resolve(answer.trim());
+ });
+ });
+}
+
+// 第三步:使用 code 参数获取访问令牌
+async function getAccessToken(code) {
+ try {
+ const form = new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: code,
+ redirect_uri: REDIRECT_URI,
+ grant_type: 'authorization_code'
+ }).toString();
+
+ const response = await axios.post(TOKEN_URL, form, {
+ // 提醒:需正确配置请求头,否则无法正常获取访问令牌
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json'
+ }
+ });
+
+ return response.data;
+ } catch (error) {
+ console.error(`获取访问令牌失败:${error.response ? JSON.stringify(error.response.data) : error.message}`);
+ throw error;
+ }
+}
+
+// 第四步:使用访问令牌获取用户信息
+async function getUserInfo(accessToken) {
+ try {
+ const response = await axios.get(USER_INFO_URL, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ });
+
+ return response.data;
+ } catch (error) {
+ console.error(`获取用户信息失败:${error.response ? JSON.stringify(error.response.data) : error.message}`);
+ throw error;
+ }
+}
+
+// 主流程
+async function main() {
+ // 1. 生成授权 URL,前端引导用户访问授权页
+ const authUrl = getAuthUrl();
+ console.log(`请访问此 URL 授权:${authUrl}
+`);
+
+ // 2. 用户授权后,从回调 URL 获取 code 参数
+ const code = await getCode();
+
+ try {
+ // 3. 使用 code 参数获取访问令牌
+ const tokenData = await getAccessToken(code);
+ const accessToken = tokenData.access_token;
+
+ // 4. 使用访问令牌获取用户信息
+ if (accessToken) {
+ const userInfo = await getUserInfo(accessToken);
+ console.log(`
+获取用户信息成功:${JSON.stringify(userInfo, null, 2)}`);
+ } else {
+ console.log(`
+获取访问令牌失败:${JSON.stringify(tokenData)}`);
+ }
+ } catch (error) {
+ console.error('发生错误:', error);
+ }
+}
+```
+Python
+```python
+# 安装第三方请求库,本例中使用 requests
+# pip install requests
+
+# 通过 OAuth2 获取 Linux Do 用户信息的参考流程
+import requests
+import json
+
+# 配置信息(建议通过环境变量配置,避免使用硬编码)
+CLIENT_ID = '你的 Client ID'
+CLIENT_SECRET = '你的 Client Secret'
+REDIRECT_URI = '你的回调地址'
+AUTH_URL = 'https://connect.linux.do/oauth2/authorize'
+TOKEN_URL = 'https://connect.linux.do/oauth2/token'
+USER_INFO_URL = 'https://connect.linux.do/api/user'
+
+# 第一步:生成授权 URL
+def get_auth_url():
+ params = {
+ 'client_id': CLIENT_ID,
+ 'redirect_uri': REDIRECT_URI,
+ 'response_type': 'code',
+ 'scope': 'user'
+ }
+ auth_url = f"{AUTH_URL}?{'&'.join(f'{k}={v}' for k, v in params.items())}"
+ return auth_url
+
+# 第二步:获取 code 参数
+def get_code():
+ # 本例中使用终端输入来模拟流程,仅供本地测试
+ # 请在实际应用中替换为真实的处理逻辑
+ return input('从回调 URL 中提取出 code,粘贴到此处并按回车:').strip()
+
+# 第三步:使用 code 参数获取访问令牌
+def get_access_token(code):
+ try:
+ data = {
+ 'client_id': CLIENT_ID,
+ 'client_secret': CLIENT_SECRET,
+ 'code': code,
+ 'redirect_uri': REDIRECT_URI,
+ 'grant_type': 'authorization_code'
+ }
+ # 提醒:需正确配置请求头,否则无法正常获取访问令牌
+ headers = {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json'
+ }
+ response = requests.post(TOKEN_URL, data=data, headers=headers)
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ print(f"获取访问令牌失败:{e}")
+ return None
+
+# 第四步:使用访问令牌获取用户信息
+def get_user_info(access_token):
+ try:
+ headers = {
+ 'Authorization': f'Bearer {access_token}'
+ }
+ response = requests.get(USER_INFO_URL, headers=headers)
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ print(f"获取用户信息失败:{e}")
+ return None
+
+# 主流程
+if __name__ == '__main__':
+ # 1. 生成授权 URL,前端引导用户访问授权页
+ auth_url = get_auth_url()
+ print(f'请访问此 URL 授权:{auth_url}
+')
+
+ # 2. 用户授权后,从回调 URL 获取 code 参数
+ code = get_code()
+
+ # 3. 使用 code 参数获取访问令牌
+ token_data = get_access_token(code)
+ if token_data:
+ access_token = token_data.get('access_token')
+
+ # 4. 使用访问令牌获取用户信息
+ if access_token:
+ user_info = get_user_info(access_token)
+ if user_info:
+ print(f"
+获取用户信息成功:{json.dumps(user_info, indent=2)}")
+ else:
+ print("
+获取用户信息失败")
+ else:
+ print(f"
+获取访问令牌失败:{json.dumps(token_data, indent=2)}")
+ else:
+ print("
+获取访问令牌失败")
+```
+PHP
+```php
+// 通过 OAuth2 获取 Linux Do 用户信息的参考流程
+
+// 配置信息
+$CLIENT_ID = '你的 Client ID';
+$CLIENT_SECRET = '你的 Client Secret';
+$REDIRECT_URI = '你的回调地址';
+$AUTH_URL = 'https://connect.linux.do/oauth2/authorize';
+$TOKEN_URL = 'https://connect.linux.do/oauth2/token';
+$USER_INFO_URL = 'https://connect.linux.do/api/user';
+
+// 生成授权 URL
+function getAuthUrl($clientId, $redirectUri) {
+ global $AUTH_URL;
+ return $AUTH_URL . '?' . http_build_query([
+ 'client_id' => $clientId,
+ 'redirect_uri' => $redirectUri,
+ 'response_type' => 'code',
+ 'scope' => 'user'
+ ]);
+}
+
+// 使用 code 参数获取用户信息(合并获取令牌和获取用户信息的步骤)
+function getUserInfoWithCode($code, $clientId, $clientSecret, $redirectUri) {
+ global $TOKEN_URL, $USER_INFO_URL;
+
+ // 1. 获取访问令牌
+ $ch = curl_init($TOKEN_URL);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
+ 'client_id' => $clientId,
+ 'client_secret' => $clientSecret,
+ 'code' => $code,
+ 'redirect_uri' => $redirectUri,
+ 'grant_type' => 'authorization_code'
+ ]));
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Content-Type: application/x-www-form-urlencoded',
+ 'Accept: application/json'
+ ]);
+
+ $tokenResponse = curl_exec($ch);
+ curl_close($ch);
+
+ $tokenData = json_decode($tokenResponse, true);
+ if (!isset($tokenData['access_token'])) {
+ return ['error' => '获取访问令牌失败', 'details' => $tokenData];
+ }
+
+ // 2. 获取用户信息
+ $ch = curl_init($USER_INFO_URL);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Authorization: Bearer ' . $tokenData['access_token']
+ ]);
+
+ $userResponse = curl_exec($ch);
+ curl_close($ch);
+
+ return json_decode($userResponse, true);
+}
+
+// 主流程
+// 1. 生成授权 URL
+$authUrl = getAuthUrl($CLIENT_ID, $REDIRECT_URI);
+echo "使用 Linux Do 登录";
+
+// 2. 处理回调并获取用户信息
+if (isset($_GET['code'])) {
+ $userInfo = getUserInfoWithCode(
+ $_GET['code'],
+ $CLIENT_ID,
+ $CLIENT_SECRET,
+ $REDIRECT_URI
+ );
+
+ if (isset($userInfo['error'])) {
+ echo '错误: ' . $userInfo['error'];
+ } else {
+ echo '欢迎, ' . $userInfo['name'] . '!';
+ // 处理用户登录逻辑...
+ }
+}
+```
+
+## 使用说明
+
+### 授权流程
+
+1. 用户点击应用中的’使用 Linux Do 登录’按钮
+2. 系统将用户重定向至 Linux Do 的授权页面
+3. 用户完成授权后,系统自动重定向回应用并携带授权码
+4. 应用使用授权码获取访问令牌
+5. 使用访问令牌获取用户信息
+
+### 安全建议
+
+- 切勿在前端代码中暴露 Client Secret
+- 对所有用户输入数据进行严格验证
+- 确保使用 HTTPS 协议传输数据
+- 定期更新并妥善保管 Client Secret
\ No newline at end of file
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 17e51c38..79e0dd8a 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.1
+0.1.46
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index c1e15290..af51c8ed 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"log"
+ "net/url"
"os"
"strings"
"time"
@@ -35,24 +36,25 @@ const (
)
type Config struct {
- Server ServerConfig `mapstructure:"server"`
- CORS CORSConfig `mapstructure:"cors"`
- Security SecurityConfig `mapstructure:"security"`
- Billing BillingConfig `mapstructure:"billing"`
- Turnstile TurnstileConfig `mapstructure:"turnstile"`
- Database DatabaseConfig `mapstructure:"database"`
- Redis RedisConfig `mapstructure:"redis"`
- JWT JWTConfig `mapstructure:"jwt"`
- Default DefaultConfig `mapstructure:"default"`
- RateLimit RateLimitConfig `mapstructure:"rate_limit"`
- Pricing PricingConfig `mapstructure:"pricing"`
- Gateway GatewayConfig `mapstructure:"gateway"`
- Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
- TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
- RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
- Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
- Gemini GeminiConfig `mapstructure:"gemini"`
- Update UpdateConfig `mapstructure:"update"`
+ Server ServerConfig `mapstructure:"server"`
+ CORS CORSConfig `mapstructure:"cors"`
+ Security SecurityConfig `mapstructure:"security"`
+ Billing BillingConfig `mapstructure:"billing"`
+ Turnstile TurnstileConfig `mapstructure:"turnstile"`
+ Database DatabaseConfig `mapstructure:"database"`
+ Redis RedisConfig `mapstructure:"redis"`
+ JWT JWTConfig `mapstructure:"jwt"`
+ LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
+ Default DefaultConfig `mapstructure:"default"`
+ RateLimit RateLimitConfig `mapstructure:"rate_limit"`
+ Pricing PricingConfig `mapstructure:"pricing"`
+ Gateway GatewayConfig `mapstructure:"gateway"`
+ Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
+ TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
+ RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
+ Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
+ Gemini GeminiConfig `mapstructure:"gemini"`
+ Update UpdateConfig `mapstructure:"update"`
}
// UpdateConfig 在线更新相关配置
@@ -322,6 +324,30 @@ type TurnstileConfig struct {
Required bool `mapstructure:"required"`
}
+// LinuxDoConnectConfig controls LinuxDo Connect OAuth login (end-user SSO).
+//
+// Note: This is NOT the same as upstream account OAuth (e.g. OpenAI/Gemini).
+// It is used for logging in to Sub2API itself.
+type LinuxDoConnectConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ ClientID string `mapstructure:"client_id"`
+ ClientSecret string `mapstructure:"client_secret"`
+ AuthorizeURL string `mapstructure:"authorize_url"`
+ TokenURL string `mapstructure:"token_url"`
+ UserInfoURL string `mapstructure:"userinfo_url"`
+ 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)
+ 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.
+ UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
+ UserInfoIDPath string `mapstructure:"userinfo_id_path"`
+ UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
+}
+
type DefaultConfig struct {
AdminEmail string `mapstructure:"admin_email"`
AdminPassword string `mapstructure:"admin_password"`
@@ -388,6 +414,18 @@ func Load() (*Config, error) {
cfg.Server.Mode = "debug"
}
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
+ cfg.LinuxDo.ClientID = strings.TrimSpace(cfg.LinuxDo.ClientID)
+ cfg.LinuxDo.ClientSecret = strings.TrimSpace(cfg.LinuxDo.ClientSecret)
+ cfg.LinuxDo.AuthorizeURL = strings.TrimSpace(cfg.LinuxDo.AuthorizeURL)
+ cfg.LinuxDo.TokenURL = strings.TrimSpace(cfg.LinuxDo.TokenURL)
+ cfg.LinuxDo.UserInfoURL = strings.TrimSpace(cfg.LinuxDo.UserInfoURL)
+ cfg.LinuxDo.Scopes = strings.TrimSpace(cfg.LinuxDo.Scopes)
+ cfg.LinuxDo.RedirectURL = strings.TrimSpace(cfg.LinuxDo.RedirectURL)
+ cfg.LinuxDo.FrontendRedirectURL = strings.TrimSpace(cfg.LinuxDo.FrontendRedirectURL)
+ cfg.LinuxDo.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.LinuxDo.TokenAuthMethod))
+ cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
+ cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
+ cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath)
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
@@ -426,6 +464,77 @@ func Load() (*Config, error) {
return &cfg, nil
}
+func validateAbsoluteHTTPURL(raw string) error {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return fmt.Errorf("empty url")
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return err
+ }
+ if !u.IsAbs() {
+ return fmt.Errorf("must be absolute")
+ }
+ if !isHTTPScheme(u.Scheme) {
+ return fmt.Errorf("unsupported scheme: %s", u.Scheme)
+ }
+ if strings.TrimSpace(u.Host) == "" {
+ return fmt.Errorf("missing host")
+ }
+ if u.Fragment != "" {
+ return fmt.Errorf("must not include fragment")
+ }
+ return nil
+}
+
+func validateFrontendRedirectURL(raw string) error {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return fmt.Errorf("empty url")
+ }
+ if strings.ContainsAny(raw, "\r\n") {
+ return fmt.Errorf("contains invalid characters")
+ }
+ if strings.HasPrefix(raw, "/") {
+ if strings.HasPrefix(raw, "//") {
+ return fmt.Errorf("must not start with //")
+ }
+ return nil
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return err
+ }
+ if !u.IsAbs() {
+ return fmt.Errorf("must be absolute http(s) url or relative path")
+ }
+ if !isHTTPScheme(u.Scheme) {
+ return fmt.Errorf("unsupported scheme: %s", u.Scheme)
+ }
+ if strings.TrimSpace(u.Host) == "" {
+ return fmt.Errorf("missing host")
+ }
+ if u.Fragment != "" {
+ return fmt.Errorf("must not include fragment")
+ }
+ return nil
+}
+
+func isHTTPScheme(scheme string) bool {
+ return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https")
+}
+
+func warnIfInsecureURL(field, raw string) {
+ u, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil {
+ return
+ }
+ if strings.EqualFold(u.Scheme, "http") {
+ log.Printf("Warning: %s uses http scheme; use https in production to avoid token leakage.", field)
+ }
+}
+
func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
@@ -475,6 +584,22 @@ func setDefaults() {
// Turnstile
viper.SetDefault("turnstile.required", false)
+ // LinuxDo Connect OAuth login (end-user SSO)
+ viper.SetDefault("linuxdo_connect.enabled", false)
+ viper.SetDefault("linuxdo_connect.client_id", "")
+ viper.SetDefault("linuxdo_connect.client_secret", "")
+ viper.SetDefault("linuxdo_connect.authorize_url", "https://connect.linux.do/oauth2/authorize")
+ viper.SetDefault("linuxdo_connect.token_url", "https://connect.linux.do/oauth2/token")
+ viper.SetDefault("linuxdo_connect.userinfo_url", "https://connect.linux.do/api/user")
+ viper.SetDefault("linuxdo_connect.scopes", "user")
+ viper.SetDefault("linuxdo_connect.redirect_url", "")
+ viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback")
+ viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post")
+ viper.SetDefault("linuxdo_connect.use_pkce", false)
+ viper.SetDefault("linuxdo_connect.userinfo_email_path", "")
+ viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
+ viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
+
// Database
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", 5432)
@@ -586,6 +711,60 @@ func (c *Config) Validate() error {
if c.Security.CSP.Enabled && strings.TrimSpace(c.Security.CSP.Policy) == "" {
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
}
+ if c.LinuxDo.Enabled {
+ if strings.TrimSpace(c.LinuxDo.ClientID) == "" {
+ return fmt.Errorf("linuxdo_connect.client_id is required when linuxdo_connect.enabled=true")
+ }
+ if strings.TrimSpace(c.LinuxDo.AuthorizeURL) == "" {
+ return fmt.Errorf("linuxdo_connect.authorize_url is required when linuxdo_connect.enabled=true")
+ }
+ if strings.TrimSpace(c.LinuxDo.TokenURL) == "" {
+ return fmt.Errorf("linuxdo_connect.token_url is required when linuxdo_connect.enabled=true")
+ }
+ if strings.TrimSpace(c.LinuxDo.UserInfoURL) == "" {
+ return fmt.Errorf("linuxdo_connect.userinfo_url is required when linuxdo_connect.enabled=true")
+ }
+ if strings.TrimSpace(c.LinuxDo.RedirectURL) == "" {
+ return fmt.Errorf("linuxdo_connect.redirect_url is required when linuxdo_connect.enabled=true")
+ }
+ method := strings.ToLower(strings.TrimSpace(c.LinuxDo.TokenAuthMethod))
+ switch method {
+ case "", "client_secret_post", "client_secret_basic", "none":
+ default:
+ return fmt.Errorf("linuxdo_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
+ }
+ if method == "none" && !c.LinuxDo.UsePKCE {
+ return fmt.Errorf("linuxdo_connect.use_pkce must be true when linuxdo_connect.token_auth_method=none")
+ }
+ if (method == "" || method == "client_secret_post" || method == "client_secret_basic") && strings.TrimSpace(c.LinuxDo.ClientSecret) == "" {
+ return fmt.Errorf("linuxdo_connect.client_secret is required when linuxdo_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic")
+ }
+ if strings.TrimSpace(c.LinuxDo.FrontendRedirectURL) == "" {
+ return fmt.Errorf("linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true")
+ }
+
+ if err := validateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil {
+ return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err)
+ }
+ if err := validateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil {
+ return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err)
+ }
+ if err := validateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil {
+ return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err)
+ }
+ if err := validateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil {
+ return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err)
+ }
+ if err := validateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil {
+ return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err)
+ }
+
+ warnIfInsecureURL("linuxdo_connect.authorize_url", c.LinuxDo.AuthorizeURL)
+ warnIfInsecureURL("linuxdo_connect.token_url", c.LinuxDo.TokenURL)
+ warnIfInsecureURL("linuxdo_connect.userinfo_url", c.LinuxDo.UserInfoURL)
+ warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL)
+ warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
+ }
if c.Billing.CircuitBreaker.Enabled {
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index f28680c6..a39d41f9 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "strings"
"testing"
"time"
@@ -90,3 +91,53 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
t.Fatalf("ResponseHeaders.Enabled = true, want false")
}
}
+
+func TestValidateLinuxDoFrontendRedirectURL(t *testing.T) {
+ viper.Reset()
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ cfg.LinuxDo.Enabled = true
+ cfg.LinuxDo.ClientID = "test-client"
+ cfg.LinuxDo.ClientSecret = "test-secret"
+ cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback"
+ cfg.LinuxDo.TokenAuthMethod = "client_secret_post"
+ cfg.LinuxDo.UsePKCE = false
+
+ cfg.LinuxDo.FrontendRedirectURL = "javascript:alert(1)"
+ err = cfg.Validate()
+ if err == nil {
+ t.Fatalf("Validate() expected error for javascript scheme, got nil")
+ }
+ if !strings.Contains(err.Error(), "linuxdo_connect.frontend_redirect_url") {
+ t.Fatalf("Validate() expected frontend_redirect_url error, got: %v", err)
+ }
+}
+
+func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) {
+ viper.Reset()
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ cfg.LinuxDo.Enabled = true
+ cfg.LinuxDo.ClientID = "test-client"
+ cfg.LinuxDo.ClientSecret = ""
+ cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback"
+ cfg.LinuxDo.FrontendRedirectURL = "/auth/linuxdo/callback"
+ cfg.LinuxDo.TokenAuthMethod = "none"
+ cfg.LinuxDo.UsePKCE = false
+
+ err = cfg.Validate()
+ if err == nil {
+ t.Fatalf("Validate() expected error when token_auth_method=none and use_pkce=false, got nil")
+ }
+ if !strings.Contains(err.Error(), "linuxdo_connect.use_pkce") {
+ t.Fatalf("Validate() expected use_pkce error, got: %v", err)
+ }
+}
diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go
new file mode 100644
index 00000000..07310213
--- /dev/null
+++ b/backend/internal/handler/auth_linuxdo_oauth.go
@@ -0,0 +1,517 @@
+package handler
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+
+ "github.com/gin-gonic/gin"
+ "github.com/imroc/req/v3"
+ "github.com/tidwall/gjson"
+)
+
+const (
+ linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo"
+ linuxDoOAuthStateCookieName = "linuxdo_oauth_state"
+ linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier"
+ linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect"
+ linuxDoOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
+ linuxDoOAuthDefaultRedirectTo = "/dashboard"
+ linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback"
+
+ linuxDoOAuthMaxRedirectLen = 2048
+ linuxDoOAuthMaxFragmentValueLen = 512
+ linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-")
+)
+
+type linuxDoTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token,omitempty"`
+ Scope string `json:"scope,omitempty"`
+}
+
+// LinuxDoOAuthStart starts the LinuxDo Connect OAuth login flow.
+// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
+func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
+ cfg, err := linuxDoOAuthConfig(h.cfg)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ state, err := oauth.GenerateState()
+ if err != nil {
+ response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
+ return
+ }
+
+ redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
+ if redirectTo == "" {
+ redirectTo = linuxDoOAuthDefaultRedirectTo
+ }
+
+ secureCookie := isRequestHTTPS(c)
+ setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie)
+ setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
+
+ codeChallenge := ""
+ if cfg.UsePKCE {
+ verifier, err := oauth.GenerateCodeVerifier()
+ if err != nil {
+ response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err))
+ return
+ }
+ codeChallenge = oauth.GenerateCodeChallenge(verifier)
+ setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie)
+ }
+
+ redirectURI := strings.TrimSpace(cfg.RedirectURL)
+ if redirectURI == "" {
+ response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured"))
+ return
+ }
+
+ authURL, err := buildLinuxDoAuthorizeURL(cfg, state, codeChallenge, redirectURI)
+ if err != nil {
+ response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
+ return
+ }
+
+ c.Redirect(http.StatusFound, authURL)
+}
+
+// LinuxDoOAuthCallback handles the OAuth callback, creates/logins the user, then redirects to frontend.
+// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
+func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
+ cfg, cfgErr := linuxDoOAuthConfig(h.cfg)
+ if cfgErr != nil {
+ response.ErrorFrom(c, cfgErr)
+ return
+ }
+
+ frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
+ if frontendCallback == "" {
+ frontendCallback = linuxDoOAuthDefaultFrontendCB
+ }
+
+ if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
+ redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
+ return
+ }
+
+ code := strings.TrimSpace(c.Query("code"))
+ state := strings.TrimSpace(c.Query("state"))
+ if code == "" || state == "" {
+ redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
+ return
+ }
+
+ secureCookie := isRequestHTTPS(c)
+ defer func() {
+ clearCookie(c, linuxDoOAuthStateCookieName, secureCookie)
+ clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie)
+ clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
+ }()
+
+ expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
+ if err != nil || expectedState == "" || state != expectedState {
+ redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
+ return
+ }
+
+ redirectTo, _ := readCookieDecoded(c, linuxDoOAuthRedirectCookie)
+ redirectTo = sanitizeFrontendRedirectPath(redirectTo)
+ if redirectTo == "" {
+ redirectTo = linuxDoOAuthDefaultRedirectTo
+ }
+
+ codeVerifier := ""
+ if cfg.UsePKCE {
+ codeVerifier, _ = readCookieDecoded(c, linuxDoOAuthVerifierCookie)
+ if codeVerifier == "" {
+ redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
+ return
+ }
+ }
+
+ redirectURI := strings.TrimSpace(cfg.RedirectURL)
+ if redirectURI == "" {
+ redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "")
+ return
+ }
+
+ tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
+ if err != nil {
+ log.Printf("[LinuxDo OAuth] token exchange failed: %v", err)
+ redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", "")
+ return
+ }
+
+ email, username, _, 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
+ }
+
+ 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
+ }
+
+ fragment := url.Values{}
+ fragment.Set("access_token", jwtToken)
+ fragment.Set("token_type", "Bearer")
+ fragment.Set("redirect", redirectTo)
+ redirectWithFragment(c, frontendCallback, fragment)
+}
+
+func linuxDoOAuthConfig(cfg *config.Config) (config.LinuxDoConnectConfig, error) {
+ if cfg == nil {
+ return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
+ }
+ if !cfg.LinuxDo.Enabled {
+ return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
+ }
+ return cfg.LinuxDo, nil
+}
+
+func linuxDoExchangeCode(
+ ctx context.Context,
+ cfg config.LinuxDoConnectConfig,
+ code string,
+ redirectURI string,
+ codeVerifier string,
+) (*linuxDoTokenResponse, error) {
+ client := req.C().SetTimeout(30 * time.Second)
+
+ form := url.Values{}
+ form.Set("grant_type", "authorization_code")
+ form.Set("client_id", cfg.ClientID)
+ form.Set("code", code)
+ form.Set("redirect_uri", redirectURI)
+ if cfg.UsePKCE {
+ form.Set("code_verifier", codeVerifier)
+ }
+
+ r := client.R().
+ SetContext(ctx).
+ SetHeader("Accept", "application/json")
+
+ switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) {
+ case "", "client_secret_post":
+ form.Set("client_secret", cfg.ClientSecret)
+ case "client_secret_basic":
+ r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret)
+ case "none":
+ default:
+ return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
+ }
+
+ var tokenResp linuxDoTokenResponse
+ resp, err := r.SetFormDataFromValues(form).SetSuccessResult(&tokenResp).Post(cfg.TokenURL)
+ if err != nil {
+ return nil, fmt.Errorf("request token: %w", err)
+ }
+ if !resp.IsSuccessState() {
+ return nil, fmt.Errorf("token exchange status=%d", resp.StatusCode)
+ }
+ if strings.TrimSpace(tokenResp.AccessToken) == "" {
+ return nil, errors.New("token response missing access_token")
+ }
+ if strings.TrimSpace(tokenResp.TokenType) == "" {
+ tokenResp.TokenType = "Bearer"
+ }
+ return &tokenResp, nil
+}
+
+func linuxDoFetchUserInfo(
+ ctx context.Context,
+ cfg config.LinuxDoConnectConfig,
+ token *linuxDoTokenResponse,
+) (email string, username string, subject string, err error) {
+ client := req.C().SetTimeout(30 * time.Second)
+ authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken)
+ if err != nil {
+ return "", "", "", fmt.Errorf("invalid token for userinfo request: %w", err)
+ }
+
+ resp, err := client.R().
+ SetContext(ctx).
+ SetHeader("Accept", "application/json").
+ SetHeader("Authorization", authorization).
+ Get(cfg.UserInfoURL)
+ if err != nil {
+ return "", "", "", fmt.Errorf("request userinfo: %w", err)
+ }
+ if !resp.IsSuccessState() {
+ return "", "", "", fmt.Errorf("userinfo status=%d", resp.StatusCode)
+ }
+
+ return linuxDoParseUserInfo(resp.String(), cfg)
+}
+
+func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email string, username string, subject string, err error) {
+ email = firstNonEmpty(
+ getGJSON(body, cfg.UserInfoEmailPath),
+ getGJSON(body, "email"),
+ getGJSON(body, "user.email"),
+ getGJSON(body, "data.email"),
+ getGJSON(body, "attributes.email"),
+ )
+ username = firstNonEmpty(
+ getGJSON(body, cfg.UserInfoUsernamePath),
+ getGJSON(body, "username"),
+ getGJSON(body, "preferred_username"),
+ getGJSON(body, "name"),
+ getGJSON(body, "user.username"),
+ getGJSON(body, "user.name"),
+ )
+ subject = firstNonEmpty(
+ getGJSON(body, cfg.UserInfoIDPath),
+ getGJSON(body, "sub"),
+ getGJSON(body, "id"),
+ getGJSON(body, "user_id"),
+ getGJSON(body, "uid"),
+ getGJSON(body, "user.id"),
+ )
+
+ subject = strings.TrimSpace(subject)
+ if subject == "" {
+ return "", "", "", errors.New("userinfo missing id field")
+ }
+ if !isSafeLinuxDoSubject(subject) {
+ return "", "", "", errors.New("userinfo returned invalid id field")
+ }
+
+ 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)
+ }
+
+ username = strings.TrimSpace(username)
+ if username == "" {
+ username = "linuxdo_" + subject
+ }
+
+ return email, username, subject, nil
+}
+
+func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, codeChallenge string, redirectURI string) (string, error) {
+ u, err := url.Parse(cfg.AuthorizeURL)
+ if err != nil {
+ return "", fmt.Errorf("parse authorize_url: %w", err)
+ }
+
+ q := u.Query()
+ q.Set("response_type", "code")
+ q.Set("client_id", cfg.ClientID)
+ q.Set("redirect_uri", redirectURI)
+ if strings.TrimSpace(cfg.Scopes) != "" {
+ q.Set("scope", cfg.Scopes)
+ }
+ q.Set("state", state)
+ if cfg.UsePKCE {
+ q.Set("code_challenge", codeChallenge)
+ q.Set("code_challenge_method", "S256")
+ }
+
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+}
+
+func redirectOAuthError(c *gin.Context, frontendCallback string, code string, message string, description string) {
+ fragment := url.Values{}
+ fragment.Set("error", truncateFragmentValue(code))
+ if strings.TrimSpace(message) != "" {
+ fragment.Set("error_message", truncateFragmentValue(message))
+ }
+ if strings.TrimSpace(description) != "" {
+ fragment.Set("error_description", truncateFragmentValue(description))
+ }
+ redirectWithFragment(c, frontendCallback, fragment)
+}
+
+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
+ }
+ if u.Scheme != "" && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
+ c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
+ return
+ }
+ u.Fragment = fragment.Encode()
+ c.Header("Cache-Control", "no-store")
+ c.Header("Pragma", "no-cache")
+ c.Redirect(http.StatusFound, u.String())
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ v = strings.TrimSpace(v)
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func getGJSON(body string, path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ res := gjson.Get(body, path)
+ if !res.Exists() {
+ return ""
+ }
+ return res.String()
+}
+
+func sanitizeFrontendRedirectPath(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ if len(path) > linuxDoOAuthMaxRedirectLen {
+ return ""
+ }
+ // Only allow same-origin relative paths (avoid open redirect).
+ if !strings.HasPrefix(path, "/") {
+ return ""
+ }
+ if strings.HasPrefix(path, "//") {
+ return ""
+ }
+ if strings.Contains(path, "://") {
+ return ""
+ }
+ if strings.ContainsAny(path, "\r\n") {
+ return ""
+ }
+ return path
+}
+
+func isRequestHTTPS(c *gin.Context) bool {
+ if c.Request.TLS != nil {
+ return true
+ }
+ proto := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")))
+ return proto == "https"
+}
+
+func encodeCookieValue(value string) string {
+ return base64.RawURLEncoding.EncodeToString([]byte(value))
+}
+
+func decodeCookieValue(value string) (string, error) {
+ raw, err := base64.RawURLEncoding.DecodeString(value)
+ if err != nil {
+ return "", err
+ }
+ return string(raw), nil
+}
+
+func readCookieDecoded(c *gin.Context, name string) (string, error) {
+ ck, err := c.Request.Cookie(name)
+ if err != nil {
+ return "", err
+ }
+ return decodeCookieValue(ck.Value)
+}
+
+func setCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: name,
+ Value: value,
+ Path: linuxDoOAuthCookiePath,
+ MaxAge: maxAgeSec,
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func clearCookie(c *gin.Context, name string, secure bool) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: name,
+ Value: "",
+ Path: linuxDoOAuthCookiePath,
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func truncateFragmentValue(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ if len(value) > linuxDoOAuthMaxFragmentValueLen {
+ value = value[:linuxDoOAuthMaxFragmentValueLen]
+ for !utf8.ValidString(value) {
+ value = value[:len(value)-1]
+ }
+ }
+ return value
+}
+
+func buildBearerAuthorization(tokenType, accessToken string) (string, error) {
+ tokenType = strings.TrimSpace(tokenType)
+ if tokenType == "" {
+ tokenType = "Bearer"
+ }
+ if !strings.EqualFold(tokenType, "Bearer") {
+ return "", fmt.Errorf("unsupported token_type: %s", tokenType)
+ }
+
+ accessToken = strings.TrimSpace(accessToken)
+ if accessToken == "" {
+ return "", errors.New("missing access_token")
+ }
+ if strings.ContainsAny(accessToken, " \t\r\n") {
+ return "", errors.New("access_token contains whitespace")
+ }
+ return "Bearer " + accessToken, nil
+}
+
+func isSafeLinuxDoSubject(subject string) bool {
+ subject = strings.TrimSpace(subject)
+ if subject == "" || len(subject) > linuxDoOAuthMaxSubjectLen {
+ return false
+ }
+ for _, r := range subject {
+ switch {
+ case r >= '0' && r <= '9':
+ case r >= 'a' && r <= 'z':
+ case r >= 'A' && r <= 'Z':
+ case r == '_' || r == '-':
+ default:
+ return false
+ }
+ }
+ return true
+}
diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go
new file mode 100644
index 00000000..03db69a8
--- /dev/null
+++ b/backend/internal/handler/auth_linuxdo_oauth_test.go
@@ -0,0 +1,74 @@
+package handler
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSanitizeFrontendRedirectPath(t *testing.T) {
+ require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath("/dashboard"))
+ require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath(" /dashboard "))
+ require.Equal(t, "", sanitizeFrontendRedirectPath("dashboard"))
+ require.Equal(t, "", sanitizeFrontendRedirectPath("//evil.com"))
+ require.Equal(t, "", sanitizeFrontendRedirectPath("https://evil.com"))
+ require.Equal(t, "", sanitizeFrontendRedirectPath("/\nfoo"))
+
+ long := "/" + strings.Repeat("a", linuxDoOAuthMaxRedirectLen)
+ require.Equal(t, "", sanitizeFrontendRedirectPath(long))
+}
+
+func TestBuildBearerAuthorization(t *testing.T) {
+ auth, err := buildBearerAuthorization("", "token123")
+ require.NoError(t, err)
+ require.Equal(t, "Bearer token123", auth)
+
+ auth, err = buildBearerAuthorization("bearer", "token123")
+ require.NoError(t, err)
+ require.Equal(t, "Bearer token123", auth)
+
+ _, err = buildBearerAuthorization("MAC", "token123")
+ require.Error(t, err)
+
+ _, err = buildBearerAuthorization("Bearer", "token 123")
+ require.Error(t, err)
+}
+
+func TestLinuxDoParseUserInfoParsesIDAndUsername(t *testing.T) {
+ cfg := config.LinuxDoConnectConfig{
+ UserInfoURL: "https://connect.linux.do/api/user",
+ }
+
+ email, username, subject, err := linuxDoParseUserInfo(`{"id":123,"username":"alice"}`, cfg)
+ require.NoError(t, err)
+ require.Equal(t, "123", subject)
+ require.Equal(t, "alice", username)
+ require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email)
+}
+
+func TestLinuxDoParseUserInfoDefaultsUsername(t *testing.T) {
+ cfg := config.LinuxDoConnectConfig{
+ UserInfoURL: "https://connect.linux.do/api/user",
+ }
+
+ email, username, subject, err := linuxDoParseUserInfo(`{"id":"123"}`, cfg)
+ require.NoError(t, err)
+ require.Equal(t, "123", subject)
+ require.Equal(t, "linuxdo_123", username)
+ require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email)
+}
+
+func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) {
+ cfg := config.LinuxDoConnectConfig{
+ UserInfoURL: "https://connect.linux.do/api/user",
+ }
+
+ _, _, _, err := linuxDoParseUserInfo(`{"id":"123@456"}`, cfg)
+ require.Error(t, err)
+
+ tooLong := strings.Repeat("a", linuxDoOAuthMaxSubjectLen+1)
+ _, _, _, err = linuxDoParseUserInfo(`{"id":"`+tooLong+`"}`, cfg)
+ require.Error(t, err)
+}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 4c50cedf..7382a577 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -50,5 +50,6 @@ type PublicSettings struct {
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version"`
}
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 3cae7a7f..e1b20c8c 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
+ LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: h.version,
})
}
diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go
index 196d8bdb..e61d3939 100644
--- a/backend/internal/server/routes/auth.go
+++ b/backend/internal/server/routes/auth.go
@@ -19,6 +19,8 @@ func RegisterAuthRoutes(
auth.POST("/register", h.Auth.Register)
auth.POST("/login", h.Auth.Login)
auth.POST("/send-verify-code", h.Auth.SendVerifyCode)
+ auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
+ auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
}
// 公开设置(无需认证)
diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go
index 6e685869..e3532b25 100644
--- a/backend/internal/service/auth_service.go
+++ b/backend/internal/service/auth_service.go
@@ -2,9 +2,13 @@ package service
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"errors"
"fmt"
"log"
+ "net/mail"
+ "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -18,6 +22,7 @@ var (
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
+ ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
@@ -27,6 +32,8 @@ var (
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
)
+const linuxDoSyntheticEmailDomain = "@linuxdo-connect.invalid"
+
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
const maxTokenLength = 8192
@@ -80,6 +87,11 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return "", nil, ErrRegDisabled
}
+ // Prevent users from registering emails reserved for synthetic OAuth accounts.
+ if isReservedEmail(email) {
+ return "", nil, ErrEmailReserved
+ }
+
// 检查是否需要邮件验证
if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) {
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
@@ -161,6 +173,10 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
return ErrRegDisabled
}
+ if isReservedEmail(email) {
+ return ErrEmailReserved
+ }
+
// 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
if err != nil {
@@ -195,6 +211,10 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
return nil, ErrRegDisabled
}
+ if isReservedEmail(email) {
+ return nil, ErrEmailReserved
+ }
+
// 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
if err != nil {
@@ -319,6 +339,101 @@ 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.
+//
+// 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.
+func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) {
+ email = strings.TrimSpace(email)
+ if email == "" || len(email) > 255 {
+ return "", nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
+ }
+ if _, err := mail.ParseAddress(email); err != nil {
+ return "", nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
+ }
+
+ username = strings.TrimSpace(username)
+ if len([]rune(username)) > 100 {
+ username = string([]rune(username)[:100])
+ }
+
+ user, err := s.userRepo.GetByEmail(ctx, email)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ // Treat OAuth-first login as registration.
+ if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) {
+ return "", nil, ErrRegDisabled
+ }
+
+ randomPassword, err := randomHexString(32)
+ if err != nil {
+ log.Printf("[Auth] Failed to generate random password for oauth signup: %v", err)
+ return "", nil, ErrServiceUnavailable
+ }
+ hashedPassword, err := s.HashPassword(randomPassword)
+ if err != nil {
+ 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 {
+ defaultBalance = s.settingService.GetDefaultBalance(ctx)
+ defaultConcurrency = s.settingService.GetDefaultConcurrency(ctx)
+ }
+
+ newUser := &User{
+ Email: email,
+ Username: username,
+ PasswordHash: hashedPassword,
+ Role: RoleUser,
+ Balance: defaultBalance,
+ Concurrency: defaultConcurrency,
+ Status: StatusActive,
+ }
+
+ if err := s.userRepo.Create(ctx, newUser); err != nil {
+ if errors.Is(err, ErrEmailExists) {
+ // Race: user created between GetByEmail and Create.
+ user, err = s.userRepo.GetByEmail(ctx, email)
+ if err != nil {
+ log.Printf("[Auth] Database error getting user after conflict: %v", err)
+ return "", nil, ErrServiceUnavailable
+ }
+ } else {
+ log.Printf("[Auth] Database error creating oauth user: %v", err)
+ return "", nil, ErrServiceUnavailable
+ }
+ } else {
+ user = newUser
+ }
+ } else {
+ log.Printf("[Auth] Database error during oauth login: %v", err)
+ return "", nil, ErrServiceUnavailable
+ }
+ }
+
+ if !user.IsActive() {
+ 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 {
+ log.Printf("[Auth] Failed to update username after oauth login: %v", err)
+ }
+ }
+
+ token, err := s.GenerateToken(user)
+ if err != nil {
+ return "", nil, fmt.Errorf("generate token: %w", err)
+ }
+ return token, user, nil
+}
+
// ValidateToken 验证JWT token并返回用户声明
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
@@ -361,6 +476,22 @@ func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
return nil, ErrInvalidToken
}
+func randomHexString(byteLength int) (string, error) {
+ if byteLength <= 0 {
+ byteLength = 16
+ }
+ buf := make([]byte, byteLength)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+func isReservedEmail(email string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(email))
+ return strings.HasSuffix(normalized, linuxDoSyntheticEmailDomain)
+}
+
// GenerateToken 生成JWT token
func (s *AuthService) GenerateToken(user *User) (string, error) {
now := time.Now()
diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go
index bfd504a3..8e99ea29 100644
--- a/backend/internal/service/auth_service_register_test.go
+++ b/backend/internal/service/auth_service_register_test.go
@@ -182,6 +182,14 @@ func TestAuthService_Register_CheckEmailError(t *testing.T) {
require.ErrorIs(t, err, ErrServiceUnavailable)
}
+func TestAuthService_Register_ReservedEmail(t *testing.T) {
+ repo := &userRepoStub{}
+ service := newAuthService(repo, nil, nil)
+
+ _, _, err := service.Register(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "password")
+ require.ErrorIs(t, err, ErrEmailReserved)
+}
+
func TestAuthService_Register_CreateError(t *testing.T) {
repo := &userRepoStub{createErr: errors.New("create failed")}
service := newAuthService(repo, map[string]string{
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 965253cf..b3a3bf21 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -82,6 +82,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
APIBaseURL: settings[SettingKeyAPIBaseURL],
ContactInfo: settings[SettingKeyContactInfo],
DocURL: settings[SettingKeyDocURL],
+ LinuxDoOAuthEnabled: s.cfg != nil && s.cfg.LinuxDo.Enabled,
}, nil
}
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index de0331f7..a06723f8 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -51,5 +51,6 @@ type PublicSettings struct {
APIBaseURL string
ContactInfo string
DocURL string
+ LinuxDoOAuthEnabled bool
Version string
}
diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml
index 49bf0afa..936f0ea4 100644
--- a/deploy/config.example.yaml
+++ b/deploy/config.example.yaml
@@ -234,6 +234,31 @@ jwt:
# 令牌过期时间(小时,最大 24)
expire_hour: 24
+# =============================================================================
+# LinuxDo Connect OAuth Login (SSO)
+# LinuxDo Connect OAuth 登录(用于 Sub2API 用户登录)
+# =============================================================================
+linuxdo_connect:
+ enabled: false
+ client_id: ""
+ client_secret: ""
+ authorize_url: "https://connect.linux.do/oauth2/authorize"
+ token_url: "https://connect.linux.do/oauth2/token"
+ userinfo_url: "https://connect.linux.do/api/user"
+ scopes: "user"
+ # 示例: "https://your-domain.com/api/v1/auth/oauth/linuxdo/callback"
+ redirect_url: ""
+ # 安全提示:
+ # - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
+ # - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
+ frontend_redirect_url: "/auth/linuxdo/callback"
+ token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none
+ # 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
+ use_pkce: false
+ userinfo_email_path: ""
+ userinfo_id_path: ""
+ userinfo_username_path: ""
+
# =============================================================================
# Default Settings
# 默认设置
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 2732d84d..745445bf 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -229,6 +229,15 @@ export default {
sendingCode: 'Sending...',
clickToResend: 'Click to resend code',
resendCode: 'Resend verification code',
+ linuxdo: {
+ signIn: 'Continue with Linux.do',
+ orContinue: 'or continue with email',
+ callbackTitle: 'Signing you in',
+ callbackProcessing: 'Completing login, please wait...',
+ callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
+ callbackMissingToken: 'Missing login token, please try again.',
+ backToLogin: 'Back to Login'
+ },
oauth: {
code: 'Code',
state: 'State',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 40aa39ab..83df3ddc 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -227,6 +227,15 @@ export default {
sendingCode: '发送中...',
clickToResend: '点击重新发送验证码',
resendCode: '重新发送验证码',
+ linuxdo: {
+ signIn: '使用 Linux.do 登录',
+ orContinue: '或使用邮箱密码继续',
+ callbackTitle: '正在完成登录',
+ callbackProcessing: '正在验证登录信息,请稍候...',
+ callbackHint: '如果页面未自动跳转,请返回登录页重试。',
+ callbackMissingToken: '登录信息缺失,请返回重试。',
+ backToLogin: '返回登录'
+ },
oauth: {
code: '授权码',
state: '状态',
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 48a6f0fd..238982ef 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [
title: 'OAuth Callback'
}
},
+ {
+ path: '/auth/linuxdo/callback',
+ name: 'LinuxDoOAuthCallback',
+ component: () => import('@/views/auth/LinuxDoCallbackView.vue'),
+ meta: {
+ requiresAuth: false,
+ title: 'LinuxDo OAuth Callback'
+ }
+ },
// ==================== User Routes ====================
{
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index cfc9d677..d91a9b7e 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -282,23 +282,24 @@ 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
+ {{ isProcessing ? t('auth.linuxdo.callbackProcessing') : t('auth.linuxdo.callbackHint') }}
+
+ {{ errorMessage }}
+
+ {{ t('auth.linuxdo.callbackTitle') }}
+
+