Compare commits

...
13 Commits
Author SHA1 Message Date
github-actions[bot] 8e8033bb35 Merge pull request #187 from komari-monitor/dev
feat: 支持通过环境变量显式关闭Origin校验
2025-08-10 08:50:53 +00:00
Akizon77 71b0903b0e feat: 支持通过环境变量显式关闭Origin校验 2025-08-10 08:49:23 +00:00
Gil Schneider d0eefe2dd2 feat: 新增聚合登录(QQ)功能 (#185)
* feat:新增单点登录提供商“聚合登录”;
feat:新增集合登录的登录参数设置;

* feat:移除nsmao接口支持。

* 移除调试环境生成的二进制文件
2025-08-10 15:04:49 +08:00
github-actions[bot] 2c547bab68 Merge pull request #186 from komari-monitor/dev
ci: 禁用ghcr缓存
2025-08-10 07:03:45 +00:00
Akizon77 2dc04b5ecf ci: 禁用ghcr缓存 2025-08-10 07:02:48 +00:00
github-actions[bot] e2dae1ec7b Merge pull request #182 from komari-monitor/dev
fix: 修复2FA验证逻辑
2025-08-09 08:36:41 +00:00
Akizon77 59d923e168 fix: 修复2FA验证逻辑 2025-08-09 08:35:46 +00:00
github-actions[bot] 51844c27f1 Merge pull request #181 from komari-monitor/dev
feat: 添加CheckOrigin函数并更新WebSocket连接的源检查逻辑
2025-08-09 07:31:50 +00:00
Akizon77 53171affca feat: 添加CheckOrigin函数并更新WebSocket连接的源检查逻辑 2025-08-09 07:30:44 +00:00
Akizon d31d12e59f Merge commit from fork
fix: WS跨域检查
2025-08-09 14:56:45 +08:00
Akizon77 3715f2b3f9 fix: WS跨域检查 2025-08-09 06:52:39 +00:00
github-actions[bot] cc3d54bff4 Merge pull request #180 from komari-monitor/dev
fix: 修复2FA验证逻辑中的错误处理
2025-08-09 06:40:56 +00:00
Akizon77 ef376a88b8 fix: 修复2FA验证逻辑中的错误处理 2025-08-09 06:39:48 +00:00
11 changed files with 255 additions and 20 deletions

No files matched your search

+2
View File
@@ -96,3 +96,5 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+1 -1
View File
@@ -65,7 +65,7 @@ func WebSocketReport(c *gin.Context) {
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
return true // 被控
},
}
// Upgrade the HTTP connection to a WebSocket connection
+1 -1
View File
@@ -22,7 +22,7 @@ func EstablishConnection(c *gin.Context) {
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // TODO: 检查源站
return true // 被控
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+9 -7
View File
@@ -48,13 +48,15 @@ func Login(c *gin.Context) {
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
+18 -4
View File
@@ -31,9 +31,23 @@ func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
// 获取当前OAuth提供商名称
providerName := oauth.CurrentProvider().GetName()
// 对于QQ登录,由于是通过QQ聚合登录平台中转,state可能会不匹配
// 但我们仍然需要验证state的存在性(不能是空的)
if providerName == "qq" {
if state == "" {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
} else {
// 对于其他提供商,严格验证state匹配
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
}
queries := make(map[string]string)
@@ -94,4 +108,4 @@ func OAuthCallback(c *gin.Context) {
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/admin")
}
}
+1 -3
View File
@@ -30,9 +30,7 @@ func RequestTerminal(c *gin.Context) {
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: ws.CheckOrigin,
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
+2 -1
View File
@@ -4,8 +4,9 @@ import (
_ "github.com/komari-monitor/komari/utils/oauth/factory"
_ "github.com/komari-monitor/komari/utils/oauth/generic"
_ "github.com/komari-monitor/komari/utils/oauth/github"
_ "github.com/komari-monitor/komari/utils/oauth/qq"
)
func All() {
//empty function to ensure all OIDC providers are registered
}
}
+24
View File
@@ -0,0 +1,24 @@
package qq
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &QQ{}
})
}
type QQ struct {
Addition
stateCache *cache.Cache // 用于存储state和用户信息的映射
}
type Addition struct {
AggregationURL string `json:"aggregation_url" required:"true" default:"https://login.qjqq.cn"` // 聚合登录地址
AppId string `json:"app_id" required:"true"`
AppKey string `json:"app_key" required:"true"`
LoginType string `json:"login_type" required:"true"` // 登录方式,如qq, google等
}
+171
View File
@@ -0,0 +1,171 @@
package qq
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func (q *QQ) GetName() string {
return "qq"
}
func (q *QQ) GetConfiguration() factory.Configuration {
return &q.Addition
}
func (q *QQ) GetAuthorizationURL(redirectURI string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建请求QQ聚合登录平台的URL
requestURL := fmt.Sprintf(
"%s/connect.php?act=login&appid=%s&appkey=%s&type=%s&redirect_uri=%s",
q.Addition.AggregationURL,
url.QueryEscape(q.Addition.AppId),
url.QueryEscape(q.Addition.AppKey),
url.QueryEscape(q.Addition.LoginType),
url.QueryEscape(redirectURI),
)
// 向聚合登录平台发送请求
resp, err := http.Get(requestURL)
if err != nil {
// 如果请求失败,返回错误信息
return "", state
}
defer resp.Body.Close()
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", state
}
// 解析响应JSON
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", state
}
// 检查响应状态
if result.Code != 0 {
return "", state
}
q.stateCache.Set(state, true, cache.DefaultExpiration)
return result.URL, state
}
// OnCallback 处理QQ OAuth回调
// 例如:http://localhost:25774/api/oauth_callback?type=qq&code=XXXXXXXXXXXXXXXX
// 然后我们使用code参数向聚合登录平台请求用户信息
func (q *QQ) OnCallback(ctx context.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
// 根据文档,回调地址会附带type和code参数
code := query["code"]
loginType := query["type"]
// 如果回调中没有type参数,则使用配置中的LoginType
if loginType == "" {
loginType = q.Addition.LoginType
}
// 验证state防止CSRF攻击
if q.stateCache == nil {
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
}
if _, ok := q.stateCache.Get(state); !ok {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
if state == "" {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
// 检查是否提供了Authorization Code
if code == "" {
return factory.OidcCallback{}, fmt.Errorf("no authorization code provided")
}
// 通过Authorization Code获取用户信息
// 根据文档,请求URL应为: {AggregationURL}/connect.php?act=callback&appid={appid}&appkey={appkey}&type={登录方式}&code={code}
callbackURL := fmt.Sprintf(
"%s/connect.php?act=callback&appid=%s&appkey=%s&type=%s&code=%s",
q.Addition.AggregationURL,
url.QueryEscape(q.Addition.AppId),
url.QueryEscape(q.Addition.AppKey),
url.QueryEscape(loginType),
url.QueryEscape(code),
)
resp, err := http.Get(callbackURL)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
}
defer resp.Body.Close()
// 检查HTTP响应状态
if resp.StatusCode != http.StatusOK {
return factory.OidcCallback{}, fmt.Errorf("HTTP request failed with status code: %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to read response: %v", err)
}
// 解析响应
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Type string `json:"type"`
SocialUid string `json:"social_uid"`
AccessToken string `json:"access_token"`
FaceImg string `json:"faceimg"`
Nickname string `json:"nickname"`
Gender string `json:"gender"`
Location string `json:"location"`
IP string `json:"ip"`
}
if err := json.Unmarshal(body, &result); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse callback response: %v, response body: %s", err, string(body))
}
// 检查返回状态码
if result.Code != 0 {
return factory.OidcCallback{}, fmt.Errorf("QQ login callback failed with code %d: %s", result.Code, result.Msg)
}
// 检查是否返回了用户唯一标识
if result.SocialUid == "" {
return factory.OidcCallback{}, fmt.Errorf("empty social_uid returned, full response: %s", string(body))
}
// 返回用户唯一标识
return factory.OidcCallback{UserId: result.SocialUid}, nil
}
func (q *QQ) Init() error {
q.stateCache = cache.New(time.Minute*5, time.Minute*10)
return nil
}
func (q *QQ) Destroy() error {
q.stateCache.Flush()
return nil
}
var _ factory.IOidcProvider = (*QQ)(nil)
+1 -3
View File
@@ -16,9 +16,7 @@ func GetClients(c *gin.Context) {
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: CheckOrigin,
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+25
View File
@@ -0,0 +1,25 @@
package ws
import (
"net/http"
"net/url"
"os"
"strings"
)
func CheckOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
// 显式关闭校验
if strings.EqualFold(os.Getenv("KOMARI_WS_DISABLE_ORIGIN"), "true") {
return true
}
if origin == "" {
return false
}
host := r.Host
originUrl, err := url.Parse(origin)
if err != nil {
return false
}
return originUrl.Host == host
}