feat(auth): 添加 Linux DO Connect OAuth 登录支持
- 新增 Linux DO OAuth 配置项和环境变量支持 - 实现 OAuth 授权流程和回调处理 - 前端添加 Linux DO 登录按钮和回调页面 - 支持通过 Linux DO 账号注册/登录 - 添加相关国际化文本 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
368
Linux DO Connect.md
Normal file
368
Linux DO Connect.md
Normal file
@@ -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 "<a href='$authUrl'>使用 Linux Do 登录</a>";
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -1 +1 @@
|
|||||||
0.1.1
|
0.1.46
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -35,24 +36,25 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
CORS CORSConfig `mapstructure:"cors"`
|
CORS CORSConfig `mapstructure:"cors"`
|
||||||
Security SecurityConfig `mapstructure:"security"`
|
Security SecurityConfig `mapstructure:"security"`
|
||||||
Billing BillingConfig `mapstructure:"billing"`
|
Billing BillingConfig `mapstructure:"billing"`
|
||||||
Turnstile TurnstileConfig `mapstructure:"turnstile"`
|
Turnstile TurnstileConfig `mapstructure:"turnstile"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
Redis RedisConfig `mapstructure:"redis"`
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Default DefaultConfig `mapstructure:"default"`
|
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
Default DefaultConfig `mapstructure:"default"`
|
||||||
Pricing PricingConfig `mapstructure:"pricing"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Gateway GatewayConfig `mapstructure:"gateway"`
|
Pricing PricingConfig `mapstructure:"pricing"`
|
||||||
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
|
Gateway GatewayConfig `mapstructure:"gateway"`
|
||||||
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
|
||||||
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
||||||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
||||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||||||
Update UpdateConfig `mapstructure:"update"`
|
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||||
|
Update UpdateConfig `mapstructure:"update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig 在线更新相关配置
|
// UpdateConfig 在线更新相关配置
|
||||||
@@ -322,6 +324,30 @@ type TurnstileConfig struct {
|
|||||||
Required bool `mapstructure:"required"`
|
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 {
|
type DefaultConfig struct {
|
||||||
AdminEmail string `mapstructure:"admin_email"`
|
AdminEmail string `mapstructure:"admin_email"`
|
||||||
AdminPassword string `mapstructure:"admin_password"`
|
AdminPassword string `mapstructure:"admin_password"`
|
||||||
@@ -388,6 +414,18 @@ func Load() (*Config, error) {
|
|||||||
cfg.Server.Mode = "debug"
|
cfg.Server.Mode = "debug"
|
||||||
}
|
}
|
||||||
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
|
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.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
||||||
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
||||||
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
|
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
|
||||||
@@ -426,6 +464,77 @@ func Load() (*Config, error) {
|
|||||||
return &cfg, nil
|
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() {
|
func setDefaults() {
|
||||||
viper.SetDefault("run_mode", RunModeStandard)
|
viper.SetDefault("run_mode", RunModeStandard)
|
||||||
|
|
||||||
@@ -475,6 +584,22 @@ func setDefaults() {
|
|||||||
// Turnstile
|
// Turnstile
|
||||||
viper.SetDefault("turnstile.required", false)
|
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
|
// Database
|
||||||
viper.SetDefault("database.host", "localhost")
|
viper.SetDefault("database.host", "localhost")
|
||||||
viper.SetDefault("database.port", 5432)
|
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) == "" {
|
if c.Security.CSP.Enabled && strings.TrimSpace(c.Security.CSP.Policy) == "" {
|
||||||
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
|
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.Enabled {
|
||||||
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
|
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
|
||||||
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
|
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -90,3 +91,53 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
|
|||||||
t.Fatalf("ResponseHeaders.Enabled = true, want false")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
517
backend/internal/handler/auth_linuxdo_oauth.go
Normal file
517
backend/internal/handler/auth_linuxdo_oauth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
74
backend/internal/handler/auth_linuxdo_oauth_test.go
Normal file
74
backend/internal/handler/auth_linuxdo_oauth_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -50,5 +50,6 @@ type PublicSettings struct {
|
|||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
DocURL string `json:"doc_url"`
|
DocURL string `json:"doc_url"`
|
||||||
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
APIBaseURL: settings.APIBaseURL,
|
APIBaseURL: settings.APIBaseURL,
|
||||||
ContactInfo: settings.ContactInfo,
|
ContactInfo: settings.ContactInfo,
|
||||||
DocURL: settings.DocURL,
|
DocURL: settings.DocURL,
|
||||||
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
Version: h.version,
|
Version: h.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ func RegisterAuthRoutes(
|
|||||||
auth.POST("/register", h.Auth.Register)
|
auth.POST("/register", h.Auth.Register)
|
||||||
auth.POST("/login", h.Auth.Login)
|
auth.POST("/login", h.Auth.Login)
|
||||||
auth.POST("/send-verify-code", h.Auth.SendVerifyCode)
|
auth.POST("/send-verify-code", h.Auth.SendVerifyCode)
|
||||||
|
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||||
|
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开设置(无需认证)
|
// 公开设置(无需认证)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
@@ -18,6 +22,7 @@ var (
|
|||||||
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
|
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
|
||||||
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
|
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
|
||||||
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
|
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
|
||||||
|
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
|
||||||
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
|
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
|
||||||
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
|
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
|
||||||
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
|
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
|
||||||
@@ -27,6 +32,8 @@ 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
|
||||||
|
|
||||||
@@ -80,6 +87,11 @@ 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.
|
||||||
|
if isReservedEmail(email) {
|
||||||
|
return "", nil, ErrEmailReserved
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否需要邮件验证
|
// 检查是否需要邮件验证
|
||||||
if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) {
|
if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) {
|
||||||
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
|
// 如果邮件验证已开启但邮件服务未配置,拒绝注册
|
||||||
@@ -161,6 +173,10 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
|||||||
return ErrRegDisabled
|
return ErrRegDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isReservedEmail(email) {
|
||||||
|
return ErrEmailReserved
|
||||||
|
}
|
||||||
|
|
||||||
// 检查邮箱是否已存在
|
// 检查邮箱是否已存在
|
||||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,6 +211,10 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
|
|||||||
return nil, ErrRegDisabled
|
return nil, ErrRegDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isReservedEmail(email) {
|
||||||
|
return nil, ErrEmailReserved
|
||||||
|
}
|
||||||
|
|
||||||
// 检查邮箱是否已存在
|
// 检查邮箱是否已存在
|
||||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -319,6 +339,101 @@ 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.
|
||||||
|
//
|
||||||
|
// 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并返回用户声明
|
// ValidateToken 验证JWT token并返回用户声明
|
||||||
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
||||||
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
|
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
|
||||||
@@ -361,6 +476,22 @@ func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
|
|||||||
return nil, ErrInvalidToken
|
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
|
// GenerateToken 生成JWT token
|
||||||
func (s *AuthService) GenerateToken(user *User) (string, error) {
|
func (s *AuthService) GenerateToken(user *User) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -182,6 +182,14 @@ func TestAuthService_Register_CheckEmailError(t *testing.T) {
|
|||||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
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) {
|
func TestAuthService_Register_CreateError(t *testing.T) {
|
||||||
repo := &userRepoStub{createErr: errors.New("create failed")}
|
repo := &userRepoStub{createErr: errors.New("create failed")}
|
||||||
service := newAuthService(repo, map[string]string{
|
service := newAuthService(repo, map[string]string{
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||||
ContactInfo: settings[SettingKeyContactInfo],
|
ContactInfo: settings[SettingKeyContactInfo],
|
||||||
DocURL: settings[SettingKeyDocURL],
|
DocURL: settings[SettingKeyDocURL],
|
||||||
|
LinuxDoOAuthEnabled: s.cfg != nil && s.cfg.LinuxDo.Enabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,5 +51,6 @@ type PublicSettings struct {
|
|||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
ContactInfo string
|
ContactInfo string
|
||||||
DocURL string
|
DocURL string
|
||||||
|
LinuxDoOAuthEnabled bool
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,31 @@ jwt:
|
|||||||
# 令牌过期时间(小时,最大 24)
|
# 令牌过期时间(小时,最大 24)
|
||||||
expire_hour: 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
|
# Default Settings
|
||||||
# 默认设置
|
# 默认设置
|
||||||
|
|||||||
@@ -229,6 +229,15 @@ export default {
|
|||||||
sendingCode: 'Sending...',
|
sendingCode: 'Sending...',
|
||||||
clickToResend: 'Click to resend code',
|
clickToResend: 'Click to resend code',
|
||||||
resendCode: 'Resend verification 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: {
|
oauth: {
|
||||||
code: 'Code',
|
code: 'Code',
|
||||||
state: 'State',
|
state: 'State',
|
||||||
|
|||||||
@@ -227,6 +227,15 @@ export default {
|
|||||||
sendingCode: '发送中...',
|
sendingCode: '发送中...',
|
||||||
clickToResend: '点击重新发送验证码',
|
clickToResend: '点击重新发送验证码',
|
||||||
resendCode: '重新发送验证码',
|
resendCode: '重新发送验证码',
|
||||||
|
linuxdo: {
|
||||||
|
signIn: '使用 Linux.do 登录',
|
||||||
|
orContinue: '或使用邮箱密码继续',
|
||||||
|
callbackTitle: '正在完成登录',
|
||||||
|
callbackProcessing: '正在验证登录信息,请稍候...',
|
||||||
|
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||||
|
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||||
|
backToLogin: '返回登录'
|
||||||
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
code: '授权码',
|
code: '授权码',
|
||||||
state: '状态',
|
state: '状态',
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: 'OAuth Callback'
|
title: 'OAuth Callback'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/linuxdo/callback',
|
||||||
|
name: 'LinuxDoOAuthCallback',
|
||||||
|
component: () => import('@/views/auth/LinuxDoCallbackView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: false,
|
||||||
|
title: 'LinuxDo OAuth Callback'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== User Routes ====================
|
// ==================== User Routes ====================
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -282,23 +282,24 @@ 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 {
|
return {
|
||||||
registration_enabled: false,
|
registration_enabled: false,
|
||||||
email_verify_enabled: false,
|
email_verify_enabled: false,
|
||||||
turnstile_enabled: false,
|
turnstile_enabled: false,
|
||||||
turnstile_site_key: '',
|
turnstile_site_key: '',
|
||||||
site_name: siteName.value,
|
site_name: siteName.value,
|
||||||
site_logo: siteLogo.value,
|
site_logo: siteLogo.value,
|
||||||
site_subtitle: '',
|
site_subtitle: '',
|
||||||
api_base_url: apiBaseUrl.value,
|
api_base_url: apiBaseUrl.value,
|
||||||
contact_info: contactInfo.value,
|
contact_info: contactInfo.value,
|
||||||
doc_url: docUrl.value,
|
doc_url: docUrl.value,
|
||||||
version: siteVersion.value
|
linuxdo_oauth_enabled: false,
|
||||||
}
|
version: siteVersion.value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent duplicate requests
|
// Prevent duplicate requests
|
||||||
if (publicSettingsLoading.value) {
|
if (publicSettingsLoading.value) {
|
||||||
|
|||||||
@@ -159,6 +159,27 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set token directly (OAuth/SSO callback) and load current user profile.
|
||||||
|
* @param newToken - JWT access token issued by backend
|
||||||
|
*/
|
||||||
|
async function setToken(newToken: string): Promise<User> {
|
||||||
|
// Clear any previous state first (avoid mixing sessions)
|
||||||
|
clearAuth()
|
||||||
|
|
||||||
|
token.value = newToken
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, newToken)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userData = await refreshUser()
|
||||||
|
startAutoRefresh()
|
||||||
|
return userData
|
||||||
|
} catch (error) {
|
||||||
|
clearAuth()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User logout
|
* User logout
|
||||||
* Clears all authentication state and persisted data
|
* Clears all authentication state and persisted data
|
||||||
@@ -233,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// Actions
|
// Actions
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
setToken,
|
||||||
logout,
|
logout,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
refreshUser
|
refreshUser
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface PublicSettings {
|
|||||||
api_base_url: string
|
api_base_url: string
|
||||||
contact_info: string
|
contact_info: string
|
||||||
doc_url: string
|
doc_url: string
|
||||||
|
linuxdo_oauth_enabled: boolean
|
||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
frontend/src/views/auth/LinuxDoCallbackView.vue
Normal file
119
frontend/src/views/auth/LinuxDoCallbackView.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<AuthLayout>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ t('auth.linuxdo.callbackTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ isProcessing ? t('auth.linuxdo.callbackProcessing') : t('auth.linuxdo.callbackHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-400">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<router-link to="/login" class="btn btn-primary">
|
||||||
|
{{ t('auth.linuxdo.backToLogin') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { AuthLayout } from '@/components/layout'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const isProcessing = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
function parseFragmentParams(): URLSearchParams {
|
||||||
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||||
|
return new URLSearchParams(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||||
|
if (!path) return '/dashboard'
|
||||||
|
if (!path.startsWith('/')) return '/dashboard'
|
||||||
|
if (path.startsWith('//')) return '/dashboard'
|
||||||
|
if (path.includes('://')) return '/dashboard'
|
||||||
|
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const params = parseFragmentParams()
|
||||||
|
|
||||||
|
const token = params.get('access_token') || ''
|
||||||
|
const redirect = sanitizeRedirectPath(
|
||||||
|
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||||
|
)
|
||||||
|
const error = params.get('error')
|
||||||
|
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
errorMessage.value = errorDesc || error
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
errorMessage.value = t('auth.linuxdo.callbackMissingToken')
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.setToken(token)
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(redirect)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||||
|
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -11,6 +11,51 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- LinuxDo Connect OAuth -->
|
||||||
|
<div v-if="linuxdoOAuthEnabled" class="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
@click="handleLinuxDoLogin"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="icon mr-2"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
style="color: rgb(233, 84, 32); width: 20px; height: 20px;"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g id="linuxdo_icon" data-name="linuxdo_icon">
|
||||||
|
<path
|
||||||
|
d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
|
||||||
|
fill="#EFEFEF"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
|
||||||
|
fill="#FEB005"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
|
||||||
|
fill="#1D1D1F"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{ t('auth.linuxdo.signIn') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.linuxdo.orContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||||
<!-- Email Input -->
|
<!-- Email Input -->
|
||||||
@@ -179,6 +224,7 @@ const showPassword = ref<boolean>(false)
|
|||||||
// Public settings
|
// Public settings
|
||||||
const turnstileEnabled = ref<boolean>(false)
|
const turnstileEnabled = ref<boolean>(false)
|
||||||
const turnstileSiteKey = ref<string>('')
|
const turnstileSiteKey = ref<string>('')
|
||||||
|
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||||
|
|
||||||
// Turnstile
|
// Turnstile
|
||||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||||
@@ -210,6 +256,7 @@ onMounted(async () => {
|
|||||||
const settings = await getPublicSettings()
|
const settings = await getPublicSettings()
|
||||||
turnstileEnabled.value = settings.turnstile_enabled
|
turnstileEnabled.value = settings.turnstile_enabled
|
||||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||||
|
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public settings:', error)
|
console.error('Failed to load public settings:', error)
|
||||||
}
|
}
|
||||||
@@ -320,6 +367,14 @@ 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,6 +11,51 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- LinuxDo Connect OAuth -->
|
||||||
|
<div v-if="linuxdoOAuthEnabled" class="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
@click="handleLinuxDoLogin"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="icon mr-2"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
style="color: rgb(233, 84, 32); width: 20px; height: 20px;"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g id="linuxdo_icon" data-name="linuxdo_icon">
|
||||||
|
<path
|
||||||
|
d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
|
||||||
|
fill="#EFEFEF"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
|
||||||
|
fill="#FEB005"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
|
||||||
|
fill="#1D1D1F"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{ t('auth.linuxdo.signIn') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.linuxdo.orContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Registration Disabled Message -->
|
<!-- Registration Disabled Message -->
|
||||||
<div
|
<div
|
||||||
v-if="!registrationEnabled && settingsLoaded"
|
v-if="!registrationEnabled && settingsLoaded"
|
||||||
@@ -207,6 +252,7 @@ const emailVerifyEnabled = ref<boolean>(false)
|
|||||||
const turnstileEnabled = ref<boolean>(false)
|
const turnstileEnabled = ref<boolean>(false)
|
||||||
const turnstileSiteKey = ref<string>('')
|
const turnstileSiteKey = ref<string>('')
|
||||||
const siteName = ref<string>('Sub2API')
|
const siteName = ref<string>('Sub2API')
|
||||||
|
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||||
|
|
||||||
// Turnstile
|
// Turnstile
|
||||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||||
@@ -233,6 +279,7 @@ onMounted(async () => {
|
|||||||
turnstileEnabled.value = settings.turnstile_enabled
|
turnstileEnabled.value = settings.turnstile_enabled
|
||||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||||
siteName.value = settings.site_name || 'Sub2API'
|
siteName.value = settings.site_name || 'Sub2API'
|
||||||
|
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public settings:', error)
|
console.error('Failed to load public settings:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -366,6 +413,14 @@ 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