13 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie 72f1986309 Merge branch 'dev' 2025-11-12 23:48:34 -08:00
GL.iNet-Yongping.Xie e9e773408d docs: update README with OIDC feature support details
1. Updated the README documentation to include clearer and more complete
   information about OIDC feature support.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 23:47:27 -08:00
GL.iNet-Yongping.Xie f6f06582dd feat: add whitelist support for subid and group
1. Added whitelist functionality for subid, group, and related fields to
   allow finer-grained access control and improved authentication
   flexibility.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 23:08:45 -08:00
GL.iNet-Yongping.Xie 1ab54dae68 feat: add token signature verification logic
1. Implemented token signature verification to ensure the integrity and
   authenticity of tokens before processing authentication requests.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 18:51:34 -08:00
GL.iNet-Yongping.Xie d9a66add94 feat: switch code comments from Chinese to English
1. Converted all code comments from Chinese to English to improve
   readability and better support international and English-speaking
   users.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 17:53:48 -08:00
GL.iNet-Yongping.Xie de48a9041e feat: add OIDC whitelist feature
1. Added OIDC whitelist functionality to allow specific users or domains
   to bypass standard authentication checks for controlled access.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 03:19:33 -08:00
GL.iNet-Yongping.Xie a31607ad09 feat: add support for custom OIDC login flow
Added support for a customizable OIDC login flow. Verified successful authentication using Google as the OIDC provider.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 01:54:04 -08:00
pengyu.lu a9711565cd feat: Add the function of logging in with OIDC 2025-11-12 17:51:54 +08:00
GL.iNet-Yongping.Xie b29ca0c117 doc: update Chinese and English README for LDAP login
1. Updated both Chinese and English README files to document LDAP login
   support.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-30 04:12:49 -07:00
iclannad 7ce942d0d3 Merge pull request #8 from CU-Jon/feature/LDAP
feat: add optional LDAP authentication support
2025-09-30 18:39:16 +08:00
Jon Agramonte 5e16e10e9a feat: add optional LDAP authentication support
- Introduced LDAP authentication configuration options in the README and Docker Compose files.
- Updated API to handle dual authentication methods (LDAP and legacy).
- Implemented LDAP authentication logic in a new ldap.go file.
- Enhanced user login flow to support username and password for LDAP.
- Added error handling for authorization and authentication failures.
- Updated UI to include username input when LDAP is enabled and provide authentication options.
- Added localization for new authentication messages in English and Chinese.
2025-09-29 09:59:14 -04:00
GL.iNet-Yongping.Xie 3e9e5bb93a feat: support multi-level subdomain certificates
1. Added support for multi-level subdomains and certificates, such as
   *.example.com, *.level1.example.com, and *.level2.level1.example.com.
2. Enabled remote device access through domains like
   devId.example.com, devId.level1.example.com, and
   devId.level2.level1.example.com.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-26 02:29:17 -07:00
pengyu.lu 3c6c214479 fix: Fixed the issue where the gl/main dependency could not be downloaded 2025-09-25 11:40:26 +08:00
52 changed files with 2591 additions and 867 deletions
+45 -2
View File
@@ -15,6 +15,7 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
- **Data Security** - Private deployment with full data control
- **Dedicated Bandwidth** - Exclusive bandwidth for self-hosted deployments
- **Lightweight Design** - Optimized for small businesses and individual users
- **Enterprise Authentication** - Supports both **LDAP** and **OIDC** login methods for enterprise users.
## Self-Hosting Guide
@@ -163,9 +164,51 @@ Replace the following files in:
⚠️ **Make sure the filenames remain unchanged.**
#### 🔄 Restart Services After Certificate Replacement
#### 🔐 LDAP Authentication Configuration (Optional)
After replacing the certificates, restart the GLKVM Cloud services to apply the changes:
GLKVM Cloud supports LDAP authentication for enterprise environments, allowing you to integrate with existing directory services like Active Directory, OpenLDAP, or FreeIPA.
**Key Features:**
- **Dual Authentication Mode**: Support both LDAP and traditional password authentication simultaneously
- **Group-based Authorization**: Restrict access to specific LDAP groups
- **User-based Authorization**: Allow access for specific users only
- **TLS/SSL Support**: Secure LDAP connections with encryption
- **Multiple LDAP Systems**: Compatible with Active Directory, OpenLDAP, FreeIPA, and generic LDAP servers
**Configuration:**
For detailed LDAP configuration options and setup instructions, see the [Docker Compose README](docker-compose/README.md).
**Note**: When LDAP is enabled, users can choose between:
- **LDAP Authentication**: Enter username and password for directory service authentication
- **Legacy Authentication**: Leave username empty and use the web management password
#### 🔐 OIDC Authentication Configuration (Optional)
GLKVM Cloud provides full support for **OIDC (OpenID Connect)** authentication, allowing seamless integration with modern identity providers such as **Google, Auth0, Authing** and any other standard-compliant OIDC provider.
**Key Features**
- **Modern Authentication**
Secure sign-in through any OIDC provider supporting Authorization Code Flow.
- **Email / Username / Group Whitelisting**
Restrict access based on:
- Email or domain (e.g. *@example.com*)
- Stable user ID (*sub*)
- Username (*preferred_username* or *name*)
- Groups attribute
- **Full OpenID Connect Compliance**
Supports issuer validation, token signature verification, and nonce protection.
- **Flexible Provider Support**
Works with public clouds (Google, Azure AD, Auth0, Okta) and self-hosted solutions.
**Configuration**
For detailed OIDC configuration options and setup instructions, see the
**[Docker Compose README](docker-compose/README.md)**.
#### 🔄 Restart Services After Configuration Changes
After replacing certificates or updating LDAP configuration, restart the GLKVM Cloud services to apply the changes:
```bash
cd ~/glkvm_cloud
+41 -2
View File
@@ -16,6 +16,7 @@
* **数据安全** - 私有化部署,数据完全可控
* **独享带宽** - 自部署环境下可享受专属带宽
* **轻量设计** - 专为小型企业和个人优化
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
## 自部署指南
@@ -149,9 +150,47 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
* `glkvm.cer`
* `glkvm.key`
---
#### 🔐 LDAP 身份认证配置(可选)
#### 🔄 替换证书后重启服务
GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现有的目录服务(如 Active Directory、OpenLDAP 或 FreeIPA)集成。
**主要功能:**
- **双重认证模式**:同时支持 LDAP 和传统密码认证
- **基于组的授权**:限制特定 LDAP 组访问
- **基于用户的授权**:仅允许特定用户访问
- **TLS/SSL 支持**:加密 LDAP 连接
- **多 LDAP 系统支持**:兼容 Active Directory、OpenLDAP、FreeIPA 和通用 LDAP 服务器
**配置方法:**
详细的 LDAP 配置选项和设置说明,请参见 [Docker Compose README](docker-compose/README.md)。
**注意**:启用 LDAP 后,用户可以选择:
- **LDAP 认证**:输入用户名和密码进行目录服务认证
- **传统认证**:留空用户名并使用 Web 管理密码
#### 🔐 OIDC 登录认证配置(可选)
GLKVM Cloud 完整支持 **OIDC(OpenID Connect)** 登录认证,可无缝集成现代身份提供商,例如 **Google、Auth0、Authing**,以及任何符合 OIDC 标准的认证服务。
**主要功能**
- **现代化认证方式**
支持使用任意支持 Authorization Code Flow 的 OIDC 身份提供商进行安全登录。
- **邮箱 / 用户名 / 用户组白名单控制**
可根据以下信息限制用户访问:
- 邮箱或域名(如 *@example.com*)
- 用户 ID(*sub*)
- 用户名(*preferred_username* 或 *name*)
- 用户组
- **完全符合 OpenID Connect 标准**
支持 Issuer 校验、ID Token 签名验证、Nonce 防重放保护等安全机制。
- **高度灵活的提供商支持**
兼容各类公共云 IdP(Google、Azure AD、Auth0、Okta 等)以及自建身份服务(Keycloak、Authentik、Dex 等)。
#### 🔄 配置更改后重启服务
替换证书或更新 LDAP 配置后,需要重启 GLKVM 轻量云服务以应用更改:
```bash
cd ~/glkvm_cloud
+33 -5
View File
@@ -217,7 +217,9 @@ func (srv *RttyServer) ListenAPI() error {
r.POST("/signin", func(c *gin.Context) {
type credentials struct {
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod"`
}
creds := credentials{}
@@ -228,17 +230,41 @@ func (srv *RttyServer) ListenAPI() error {
return
}
if httpLogin(cfg, creds.Password) {
// 自动确定认证方法或使用指定的方法 (Auto-determine auth method or use specified method)
authMethod := creds.AuthMethod
if authMethod == "" {
// 基于是否提供用户名进行自动检测 (Auto-detect based on whether username is provided)
if creds.Username != "" && cfg.LdapEnabled {
authMethod = "ldap"
} else {
authMethod = "legacy"
}
}
success, errorType := AuthenticateUserWithError(cfg, creds.Username, creds.Password, authMethod)
if success {
sid := utils.GenUniqueID()
httpSessions.Set(sid, true, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", false, true)
c.Status(http.StatusOK)
return
}
c.Status(http.StatusUnauthorized)
// 根据错误类型返回适当的错误信息 (Return appropriate error message based on error type)
if errorType == "authorization" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authorized"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
}
})
r.GET("/auth-config", func(c *gin.Context) {
authConfig := gin.H{
"ldapEnabled": cfg.LdapEnabled,
"legacyPassword": cfg.Password != "",
"oidcEnabled": cfg.OIDCEnabled,
}
c.JSON(http.StatusOK, authConfig)
})
r.GET("/alive", func(c *gin.Context) {
@@ -249,6 +275,8 @@ func (srv *RttyServer) ListenAPI() error {
}
})
// ===== 添加OIDC路由 =====
RegisterOIDCRoutes(r, cfg)
fs, err := fs.Sub(staticFs, "ui/dist")
if err != nil {
return err
+238 -93
View File
@@ -25,126 +25,271 @@
package main
import (
"fmt"
"strconv"
"fmt"
"os"
"strconv"
"strings"
"github.com/kylelemons/go-gypsy/yaml"
"github.com/urfave/cli/v3"
"github.com/kylelemons/go-gypsy/yaml"
"github.com/urfave/cli/v3"
)
type Config struct {
AddrDev string
AddrUser string
AddrHttpProxy string
HttpProxyRedirURL string
HttpProxyRedirDomain string
Token string
DevHookUrl string
UserHookUrl string
LocalAuth bool
Password string
AllowOrigins bool
PprofAddr string
SslCert string
SslKey string
WebrtcIP string
WebrtcPort string
WebrtcUsername string
WebrtcPassword string
AddrDev string
AddrUser string
AddrHttpProxy string
HttpProxyRedirURL string
HttpProxyRedirDomain string
Token string
DevHookUrl string
UserHookUrl string
LocalAuth bool
Password string
AllowOrigins bool
PprofAddr string
SslCert string
SslKey string
WebrtcIP string
WebrtcPort string
WebrtcUsername string
WebrtcPassword string
// // LDAP Configuration
LdapEnabled bool
LdapServer string
LdapPort int
LdapUseTLS bool
LdapBindDN string
LdapBindPassword string
LdapBaseDN string
LdapUserFilter string
LdapAllowedGroups string
LdapAllowedUsers string
// Generic OIDC Provider (supports any standard OIDC provider)
OIDCEnabled bool
OIDCGenericIssuer string
OIDCGenericClientID string
OIDCGenericClientSecret string
OIDCGenericAuthURL string
OIDCGenericTokenURL string
OIDCGenericRedirectURL string
OIDCGenericScopes []string
OIDCGenericAllowedUsers []string
OIDCGenericAllowedSubs []string
OIDCGenericAllowedUsernames []string
OIDCGenericAllowedGroups []string
}
// docker mode fixed path for reading certificate
const (
SslCert = "/home/certificate/glkvm_cer"
SslKey = "/home/certificate/glkvm_key"
SslCert = "/home/certificate/glkvm_cer"
SslKey = "/home/certificate/glkvm_key"
)
func (cfg *Config) Parse(c *cli.Command) error {
conf := c.String("conf")
if conf != "" {
err := parseYamlCfg(cfg, conf)
if err != nil {
return err
}
}
conf := c.String("conf")
if conf != "" {
err := parseYamlCfg(cfg, conf)
if err != nil {
return err
}
}
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
getFlagOpt(c, "addr-user", &cfg.AddrUser)
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
getFlagOpt(c, "token", &cfg.Token)
getFlagOpt(c, "password", &cfg.Password)
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
getFlagOpt(c, "pprof", &cfg.PprofAddr)
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
getFlagOpt(c, "addr-user", &cfg.AddrUser)
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
getFlagOpt(c, "token", &cfg.Token)
getFlagOpt(c, "password", &cfg.Password)
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
getFlagOpt(c, "pprof", &cfg.PprofAddr)
cfg.SslCert = SslCert
cfg.SslKey = SslKey
cfg.SslCert = SslCert
cfg.SslKey = SslKey
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
return nil
// LDAP configuration flags
getFlagOpt(c, "ldap-enabled", &cfg.LdapEnabled)
getFlagOpt(c, "ldap-server", &cfg.LdapServer)
getFlagOpt(c, "ldap-port", &cfg.LdapPort)
getFlagOpt(c, "ldap-use-tls", &cfg.LdapUseTLS)
getFlagOpt(c, "ldap-bind-dn", &cfg.LdapBindDN)
getFlagOpt(c, "ldap-bind-password", &cfg.LdapBindPassword)
getFlagOpt(c, "ldap-base-dn", &cfg.LdapBaseDN)
getFlagOpt(c, "ldap-user-filter", &cfg.LdapUserFilter)
getFlagOpt(c, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getFlagOpt(c, "ldap-allowed-users", &cfg.LdapAllowedUsers)
// Generic OIDC Provider (standard OIDC provider flags)
getFlagOpt(c, "oidc-enabled", &cfg.OIDCEnabled)
getFlagOpt(c, "oidc-generic-issuer", &cfg.OIDCGenericIssuer)
getFlagOpt(c, "oidc-generic-client-id", &cfg.OIDCGenericClientID)
getFlagOpt(c, "oidc-generic-client-secret", &cfg.OIDCGenericClientSecret)
getFlagOpt(c, "oidc-generic-auth-url", &cfg.OIDCGenericAuthURL)
getFlagOpt(c, "oidc-generic-token-url", &cfg.OIDCGenericTokenURL)
getFlagOpt(c, "oidc-generic-redirect-url", &cfg.OIDCGenericRedirectURL)
getFlagOpt(c, "oidc-generic-scopes", &cfg.OIDCGenericScopes)
getFlagOpt(c, "oidc-generic-allowed-users", &cfg.OIDCGenericAllowedUsers)
getFlagOpt(c, "oidc-generic-allowed-subs", &cfg.OIDCGenericAllowedSubs)
getFlagOpt(c, "oidc-generic-allowed-usernames", &cfg.OIDCGenericAllowedUsernames)
getFlagOpt(c, "oidc-generic-allowed-groups", &cfg.OIDCGenericAllowedGroups)
return nil
}
func getConfigOpt(yamlCfg *yaml.File, name string, opt any) {
val, err := yamlCfg.Get(name)
if err != nil {
return
}
val, err := yamlCfg.Get(name)
if err != nil {
return
}
switch opt := opt.(type) {
case *string:
*opt = val
case *int:
*opt, _ = strconv.Atoi(val)
case *bool:
*opt, _ = strconv.ParseBool(val)
}
switch opt := opt.(type) {
case *string:
*opt = val
case *int:
*opt, _ = strconv.Atoi(val)
case *bool:
*opt, _ = strconv.ParseBool(val)
}
}
func parseYamlCfg(cfg *Config, conf string) error {
yamlCfg, err := yaml.ReadFile(conf)
if err != nil {
return fmt.Errorf(`read config file: %s`, err.Error())
}
yamlCfg, err := yaml.ReadFile(conf)
if err != nil {
return fmt.Errorf(`read config file: %s`, err.Error())
}
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getConfigOpt(yamlCfg, "token", &cfg.Token)
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
getConfigOpt(yamlCfg, "password", &cfg.Password)
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
getConfigOpt(yamlCfg, "token", &cfg.Token)
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
getConfigOpt(yamlCfg, "password", &cfg.Password)
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
return nil
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
// LDAP配置 (LDAP Configuration)
getConfigOpt(yamlCfg, "ldap-enabled", &cfg.LdapEnabled)
getConfigOpt(yamlCfg, "ldap-server", &cfg.LdapServer)
getConfigOpt(yamlCfg, "ldap-port", &cfg.LdapPort)
getConfigOpt(yamlCfg, "ldap-use-tls", &cfg.LdapUseTLS)
getConfigOpt(yamlCfg, "ldap-bind-dn", &cfg.LdapBindDN)
// Note: ldap-bind-password is intentionally not read from YAML to avoid special character parsing issues and for security.
// It's always read directly from the LDAP_BIND_PASSWORD environment variable below
getConfigOpt(yamlCfg, "ldap-base-dn", &cfg.LdapBaseDN)
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
// LDAP password is always read from environment variable to avoid YAML special character parsing issues and for security.
if envPassword := os.Getenv("LDAP_BIND_PASSWORD"); envPassword != "" {
cfg.LdapBindPassword = envPassword
}
// ===== OIDC configuration (generic OIDC provider) =====
// Switch and basic endpoints
getConfigOpt(yamlCfg, "oidc-enabled", &cfg.OIDCEnabled)
getConfigOpt(yamlCfg, "oidc-generic-issuer", &cfg.OIDCGenericIssuer)
getConfigOpt(yamlCfg, "oidc-generic-client-id", &cfg.OIDCGenericClientID)
// Note: oidc-generic-client-secret is intentionally not read from YAML
// to avoid checking secrets into config files and leaking in logs.
// It is always read directly from the OIDC_CLIENT_SECRET environment variable below.
if envSecret := os.Getenv("OIDC_CLIENT_SECRET"); envSecret != "" {
cfg.OIDCGenericClientSecret = envSecret
}
getConfigOpt(yamlCfg, "oidc-generic-auth-url", &cfg.OIDCGenericAuthURL)
getConfigOpt(yamlCfg, "oidc-generic-token-url", &cfg.OIDCGenericTokenURL)
getConfigOpt(yamlCfg, "oidc-generic-redirect-url", &cfg.OIDCGenericRedirectURL)
// OIDC scopes (string can be space- or comma-separated, parsed by splitScopes)
if s, err := yamlCfg.Get("oidc-generic-scopes"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericScopes = splitScopes(s)
}
// Default scopes (when OIDC is enabled but scopes are still empty)
if cfg.OIDCEnabled && len(cfg.OIDCGenericScopes) == 0 {
cfg.OIDCGenericScopes = []string{"openid", "profile", "email"}
}
// Whitelists for OIDC logins, all parsed via splitScopes (space/comma/newline separated)
// 1) Email-based whitelist
if s, err := yamlCfg.Get("oidc-generic-allowed-users"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedUsers = splitScopes(s)
}
// 2) Subject (sub) whitelist
if s, err := yamlCfg.Get("oidc-generic-allowed-subs"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedSubs = splitScopes(s)
}
// 3) Username whitelist (preferred_username / name)
if s, err := yamlCfg.Get("oidc-generic-allowed-usernames"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedUsernames = splitScopes(s)
}
// 4) Groups whitelist
if s, err := yamlCfg.Get("oidc-generic-allowed-groups"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedGroups = splitScopes(s)
}
return nil
}
func getFlagOpt(c *cli.Command, name string, opt any) {
if !c.IsSet(name) {
return
}
if !c.IsSet(name) {
return
}
switch opt := opt.(type) {
case *string:
*opt = c.String(name)
case *int:
*opt = c.Int(name)
case *bool:
*opt = c.Bool(name)
}
switch opt := opt.(type) {
case *string:
*opt = c.String(name)
case *int:
*opt = c.Int(name)
case *bool:
*opt = c.Bool(name)
}
}
// splitScopes splits a whitespace/comma/newline-separated scope string
// into a deduplicated, cleaned string slice.
func splitScopes(s string) []string {
// Replace commas with spaces to unify delimiters
s = strings.ReplaceAll(s, ",", " ")
// Remove optional YAML list characters such as brackets (lenient parsing)
s = strings.NewReplacer("[", " ", "]", " ").Replace(s)
parts := strings.Fields(s)
uniq := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || p == "-" { // Support accidental "-" items
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
uniq = append(uniq, p)
}
return uniq
}
+44
View File
@@ -17,3 +17,47 @@ RTTYS_HTTP_PROXY_PORT=10443
TURN_PORT=3478
TURN_USER=glkvmcloudwebrtcuser
TURN_PASS=AnotherS3cret
# LDAP Authentication (Optional)
LDAP_ENABLED=false
LDAP_SERVER=your-ldap-server.com
LDAP_PORT=389
LDAP_USE_TLS=false
LDAP_BIND_DN=cn=service-account,ou=users,dc=company,dc=com
LDAP_BIND_PASSWORD=service-password
LDAP_BASE_DN=ou=users,dc=company,dc=com
# User filter examples for different LDAP implementations:
# Active Directory: (&(objectClass=person)(sAMAccountName=%s))
# OpenLDAP: (&(objectClass=inetOrgPerson)(uid=%s))
# FreeIPA: (&(objectClass=person)(uid=%s))
# Generic LDAP: (uid=%s)
LDAP_USER_FILTER=(uid=%s)
LDAP_ALLOWED_GROUPS=admins,operators
LDAP_ALLOWED_USERS=user1,user2
# OIDC Authentication (Optional, generic OIDC provider)
OIDC_ENABLED=false
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URL=
OIDC_TOKEN_URL=
# Redirect URL registered in your OIDC provider.
# The path part (/auth/oidc/callback) is fixed by GLKVM Cloud and must not be changed.
# Example:
# OIDC_REDIRECT_URL=https://your-domain.example.com/auth/oidc/callback
OIDC_REDIRECT_URL=
OIDC_SCOPES="openid profile email"
# Email-based whitelist (exact email or domain like @example.com)
OIDC_ALLOWED_USERS=
# Subject (sub) whitelist (stable user IDs)
OIDC_ALLOWED_SUBS=
# Username whitelist (preferred_username or name)
OIDC_ALLOWED_USERNAMES=
# Groups whitelist (e.g. admin, devops)
OIDC_ALLOWED_GROUPS=
+36
View File
@@ -19,6 +19,42 @@
- `TURN_USER` / `TURN_PASS`: coturn authentication credentials (leave empty to use the default)
- `GLKVM_ACCESS_IP`: glkvm cloud access address (leave empty to auto-detect at startup)
**LDAP Authentication (Optional):**
- `LDAP_ENABLED`: set to `true` to enable LDAP authentication (default: `false`)
- `LDAP_SERVER`: LDAP server hostname or IP address
- `LDAP_PORT`: LDAP server port (default: `389`, for TLS use `636`)
- `LDAP_USE_TLS`: set to `true` to enable TLS encryption (default: `false`)
- `LDAP_BIND_DN`: service account distinguished name
- `LDAP_BIND_PASSWORD`: service account password
- `LDAP_BASE_DN`: search base for user queries
- `LDAP_USER_FILTER`: LDAP query filter (default: `(uid=%s)`)
- `LDAP_ALLOWED_GROUPS`: comma-separated list of authorized groups (optional)
- `LDAP_ALLOWED_USERS`: comma-separated list of authorized users (optional)
⚠️ **Note:** All configuration should be done in the `.env` file.
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
**OIDC Authentication (Optional):**
- `OIDC_ENABLED`: set to `true` to enable OIDC authentication (default: `false`)
- `OIDC_ISSUER`: OIDC issuer URL provided by your identity provider
(e.g. `https://accounts.google.com`, `https://your-tenant.auth0.com/`)
- `OIDC_CLIENT_ID`: client ID issued by your OIDC provider
- `OIDC_CLIENT_SECRET`: client secret issued by your OIDC provider
*(recommended to set via environment variable rather than YAML files)*
- `OIDC_AUTH_URL`: authorization endpoint URL
- `OIDC_TOKEN_URL`: token endpoint URL
- `OIDC_REDIRECT_URL`: redirect (callback) URL registered in your OIDC provider
Domain is user-defined, but the path must be fixed: `/auth/oidc/callback`
Example: `https://your-domain.example.com/auth/oidc/callback`
- `OIDC_SCOPES`: space-separated list of requested scopes (default: `"openid profile email"`)
- `OIDC_ALLOWED_USERS`: comma-separated list of allowed emails or domains (optional)
Example: `user@example.com,@example.com`
- `OIDC_ALLOWED_SUBS`: comma-separated list of allowed OIDC subject (`sub`) IDs (optional)
- `OIDC_ALLOWED_USERNAMES`: comma-separated list of allowed usernames (`preferred_username` or `name`) (optional)
- `OIDC_ALLOWED_GROUPS`: comma-separated list of allowed OIDC groups (optional)
⚠️ **Note:** All configuration should be done in the `.env` file.
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
+27
View File
@@ -22,6 +22,33 @@ services:
TURN_PORT: ${TURN_PORT:-3478}
TURN_USER: ${TURN_USER:-glkvmcloudwebrtcuser}
TURN_PASS: ${TURN_PASS:-AnotherS3cret}
# ---- LDAP Configuration ----
LDAP_ENABLED: ${LDAP_ENABLED:-false}
LDAP_SERVER: ${LDAP_SERVER:-}
LDAP_PORT: ${LDAP_PORT:-389}
LDAP_USE_TLS: ${LDAP_USE_TLS:-false}
LDAP_BIND_DN: ${LDAP_BIND_DN:-}
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
LDAP_BASE_DN: ${LDAP_BASE_DN:-}
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
# ---- OIDC Authentication ----
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_AUTH_URL: ${OIDC_AUTH_URL:-}
OIDC_TOKEN_URL: ${OIDC_TOKEN_URL:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
# Whitelists (optional)
OIDC_ALLOWED_USERS: ${OIDC_ALLOWED_USERS:-}
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
volumes:
- ./templates/rttys.conf.template:/tpl/rttys.conf.tmpl:ro
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
+7 -1
View File
@@ -62,7 +62,13 @@ case "$1" in
render /tpl/rttys.conf.tmpl /home/rttys.conf \
RTTYS_TOKEN RTTYS_PASS \
GLKVM_ACCESS_IP TURN_PORT TURN_USER TURN_PASS \
RTTYS_DEVICE_PORT RTTYS_WEBUI_PORT RTTYS_HTTP_PROXY_PORT
RTTYS_DEVICE_PORT RTTYS_WEBUI_PORT RTTYS_HTTP_PROXY_PORT \
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
LDAP_BIND_DN LDAP_BIND_PASSWORD LDAP_BASE_DN \
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS \
OIDC_ENABLED OIDC_CLIENT_ID OIDC_AUTH_URL OIDC_TOKEN_URL \
OIDC_REDIRECT_URL OIDC_CLIENT_SECRET OIDC_SCOPES OIDC_ALLOWED_USERS OIDC_ISSUER \
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS
exec rttys -c /home/rttys.conf
;;
@@ -14,3 +14,36 @@ webrtc-password: {{TURN_PASS}}
addr-dev: :{{RTTYS_DEVICE_PORT}}
addr-user: :{{RTTYS_WEBUI_PORT}}
addr-http-proxy: :{{RTTYS_HTTP_PROXY_PORT}}
# LDAP Authentication
ldap-enabled: {{LDAP_ENABLED}}
ldap-server: {{LDAP_SERVER}}
ldap-port: {{LDAP_PORT}}
ldap-use-tls: {{LDAP_USE_TLS}}
ldap-bind-dn: {{LDAP_BIND_DN}}
# Note: ldap-bind-password is read directly from LDAP_BIND_PASSWORD environment variable
ldap-base-dn: {{LDAP_BASE_DN}}
ldap-user-filter: {{LDAP_USER_FILTER}}
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
# OIDC Authentication (generic OIDC provider)
oidc-enabled: {{OIDC_ENABLED}}
oidc-generic-issuer: {{OIDC_ISSUER}}
oidc-generic-client-id: {{OIDC_CLIENT_ID}}
# Note: oidc-generic-client-secret is read directly from the OIDC_CLIENT_SECRET
# environment variable and is intentionally not parsed from YAML to avoid
# leaking secrets in config files.
oidc-generic-auth-url: {{OIDC_AUTH_URL}}
oidc-generic-token-url: {{OIDC_TOKEN_URL}}
oidc-generic-redirect-url: {{OIDC_REDIRECT_URL}}
oidc-generic-scopes: {{OIDC_SCOPES}}
# Whitelists (all are optional)
oidc-generic-allowed-users: {{OIDC_ALLOWED_USERS}}
oidc-generic-allowed-subs: {{OIDC_ALLOWED_SUBS}}
oidc-generic-allowed-usernames: {{OIDC_ALLOWED_USERNAMES}}
oidc-generic-allowed-groups: {{OIDC_ALLOWED_GROUPS}}
+8
View File
@@ -3,11 +3,14 @@ module rttys
go 1.24.4
require (
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76
github.com/fanjindong/go-cache v0.0.6
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/go-ldap/ldap/v3 v3.4.8
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.2.1
github.com/gorilla/websocket v1.5.3
github.com/json-iterator/go v1.1.12
github.com/kylelemons/go-gypsy v1.0.0
@@ -19,15 +22,19 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -40,6 +47,7 @@ require (
golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
+81 -10
View File
@@ -1,3 +1,7 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -6,6 +10,8 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,6 +29,12 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -39,8 +51,27 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -81,6 +112,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -95,36 +127,75 @@ github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+40 -8
View File
@@ -35,6 +35,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -44,7 +45,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
"golang.org/x/net/publicsuffix"
)
type HttpProxySession struct {
@@ -346,13 +346,8 @@ func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
location = fmt.Sprintf("https://%s%s?sid=%s", hostname, cfg.AddrHttpProxy, sid)
log.Info().Msgf("Using IP redirect: %s", location)
} else {
// 域名访问,拼接 devid 子域名
eTLDPlusOne, err := publicsuffix.EffectiveTLDPlusOne(hostname)
if err != nil {
log.Info().Msgf("Error parsing domain: %v", err)
eTLDPlusOne = hostname // fallback
}
location = fmt.Sprintf("https://%s.%s%s?sid=%s", devid, eTLDPlusOne, cfg.AddrHttpProxy, sid)
redirHost := buildRedirectHost(hostname, devid)
location = fmt.Sprintf("https://%s%s?sid=%s", redirHost, cfg.AddrHttpProxy, sid)
log.Info().Msgf("Using domain redirect: %s", location)
}
@@ -624,3 +619,40 @@ func Write302WithCookie(conn net.Conn, location, cookieName, cookieValue string)
)
_, _ = conn.Write([]byte(response))
}
// buildRedirectHost removes the first label of the hostname and prepends devid.
// Rules:
// - "www.example.com" -> "devid.example.com"
// - "www.l1.example.com" -> "devid.l1.example.com"
// - "www.l1.l2.example.com" -> "devid.l1.l2.example.com"
// - Two-level domain "example.com" -> "devid.example.com"
// - Single label / abnormal cases -> "devid." + hostname (fallback)
//
// The input hostname must be a pure hostname without port.
func buildRedirectHost(hostname, devid string) string {
// Allow FQDN with trailing dot like "example.com."
hostname = strings.TrimSuffix(hostname, ".")
// Split into labels
labels := strings.Split(hostname, ".")
// Remove empty labels (in case of consecutive dots)
compact := make([]string, 0, len(labels))
for _, l := range labels {
if l != "" {
compact = append(compact, l)
}
}
labels = compact
switch len(labels) {
case 0:
return devid // extreme case: just return devid
case 1:
// Single label (e.g., "localhost") — keep original as suffix
return devid + "." + labels[0]
default:
// >=2: drop the leftmost label
suffix := strings.Join(labels[1:], ".")
return devid + "." + suffix
}
}
+376
View File
@@ -0,0 +1,376 @@
/*
* @Author: CU-Jon
* @Date: 2025-09-26 13:28:12 EDT
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\ldap.go
* @Description: LDAP认证模块 (LDAP authentication module)
*/
package main
import (
"crypto/tls"
"fmt"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog/log"
)
// LDAP认证器结构体 (LDAP authenticator struct)
type LDAPAuthenticator struct {
config *Config
}
// 创建新的LDAP认证器 (Create new LDAP authenticator)
func NewLDAPAuthenticator(config *Config) *LDAPAuthenticator {
return &LDAPAuthenticator{config: config}
}
// 执行用户LDAP认证 (Perform LDAP authentication for a user)
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, error) {
if !l.config.LdapEnabled {
return false, fmt.Errorf("LDAP authentication is disabled")
}
if username == "" || password == "" {
return false, fmt.Errorf("username and password are required")
}
// 连接到LDAP服务器 (Connect to LDAP server)
conn, err := l.connect()
if err != nil {
return false, fmt.Errorf("failed to connect to LDAP server: %v", err)
}
defer conn.Close()
// 使用服务账户进行绑定和搜索 (Use service account for binding and searching)
if l.config.LdapBindDN == "" || l.config.LdapBindPassword == "" {
return false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
}
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
if err != nil {
return false, fmt.Errorf("service account bind failed: %v", err)
} // 使用服务账户搜索用户 (Use service account to search for user)
userDN, err := l.findUserDN(conn, username)
if err != nil {
return false, fmt.Errorf("user search failed: %v", err)
}
// 找到用户,现在用用户凭证验证密码 (Found user, now validate password with user credentials)
err = conn.Bind(userDN, password)
if err != nil {
return false, fmt.Errorf("password validation failed: %v", err)
}
// 重新绑定为服务账户以进行授权检查 (Rebind as service account for authorization check)
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
if err != nil {
return false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
}
// 检查用户授权 (Check user authorization)
authorized, err := l.checkAuthorization(conn, userDN, username)
if err != nil {
return false, fmt.Errorf("authorization check failed: %v", err)
}
if !authorized {
return false, fmt.Errorf("user not authorized")
}
return true, nil
}
// 建立到LDAP服务器的连接 (Establish connection to LDAP server)
func (l *LDAPAuthenticator) connect() (*ldap.Conn, error) {
address := fmt.Sprintf("%s:%d", l.config.LdapServer, l.config.LdapPort)
var conn *ldap.Conn
var err error
if l.config.LdapUseTLS {
// TLS配置 (TLS configuration)
tlsConfig := &tls.Config{
ServerName: l.config.LdapServer,
InsecureSkipVerify: true, // 跳过证书验证以避免自签名证书问题 (Skip certificate verification to avoid self-signed certificate issues)
}
if l.config.LdapPort == 636 {
// 使用LDAPS (直接TLS连接) (Use LDAPS - direct TLS connection)
conn, err = ldap.DialTLS("tcp", address, tlsConfig)
} else {
// 使用StartTLS (先连接再升级到TLS) (Use StartTLS - connect first then upgrade to TLS)
conn, err = ldap.Dial("tcp", address)
if err == nil {
err = conn.StartTLS(tlsConfig)
}
}
} else {
// 使用普通连接 (Use plain connection)
conn, err = ldap.Dial("tcp", address)
}
if err != nil {
return nil, err
}
// 设置超时时间 (Set timeout)
conn.SetTimeout(10 * time.Second)
return conn, nil
}
// 基于用户名搜索用户DN (Search for user DN based on username)
func (l *LDAPAuthenticator) findUserDN(conn *ldap.Conn, username string) (string, error) {
// 准备搜索过滤器 (Prepare search filter)
filter := fmt.Sprintf(l.config.LdapUserFilter, username)
if l.config.LdapUserFilter == "" {
filter = fmt.Sprintf("(uid=%s)", username)
}
// 执行搜索 (Perform search)
searchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, // 无大小限制 (No size limit)
0, // 无时间限制 (No time limit)
false,
filter,
[]string{"dn"},
nil,
)
sr, err := conn.Search(searchRequest)
if err != nil {
return "", err
}
if len(sr.Entries) == 0 {
return "", fmt.Errorf("user not found")
}
if len(sr.Entries) > 1 {
return "", fmt.Errorf("multiple users found")
}
return sr.Entries[0].DN, nil
}
// 基于组或用户列表检查用户是否授权 (Check if user is authorized based on groups or users list)
func (l *LDAPAuthenticator) checkAuthorization(conn *ldap.Conn, userDN, username string) (bool, error) {
// 如果没有配置限制,则允许所有已认证用户 (If no restrictions are configured, allow all authenticated users)
if l.config.LdapAllowedGroups == "" && l.config.LdapAllowedUsers == "" {
return true, nil
}
// 检查允许的用户列表 (Check allowed users list)
if l.config.LdapAllowedUsers != "" {
allowedUsers := strings.Split(strings.TrimSpace(l.config.LdapAllowedUsers), ",")
for _, allowedUser := range allowedUsers {
if strings.TrimSpace(allowedUser) == username {
return true, nil
}
}
}
// 检查允许的组 (Check allowed groups)
if l.config.LdapAllowedGroups != "" {
return l.checkGroupMembership(conn, userDN, username)
}
return false, nil
}
// 检查用户是否属于任何允许的组 (Check if user belongs to any of the allowed groups)
func (l *LDAPAuthenticator) checkGroupMembership(conn *ldap.Conn, userDN, username string) (bool, error) {
allowedGroups := strings.Split(strings.TrimSpace(l.config.LdapAllowedGroups), ",")
for _, group := range allowedGroups {
group = strings.TrimSpace(group)
if group == "" {
continue
}
// 搜索组成员关系 - 尝试不同的常见LDAP组结构 (Search for group membership - try different common LDAP group structures)
isMember, err := l.isGroupMember(conn, userDN, username, group)
if err != nil {
log.Warn().Msgf("Error checking group membership for %s in %s: %v", username, group, err)
continue
}
if isMember {
return true, nil
}
}
return false, nil
}
// 检查用户是否是指定组的成员 (Check if user is a member of the specified group)
func (l *LDAPAuthenticator) isGroupMember(conn *ldap.Conn, userDN, username, groupName string) (bool, error) {
// 首先查找用户的实际DN,因为我们可能使用了UPN格式进行认证 (First find the user's actual DN, as we may have used UPN format for authentication)
actualUserDN, err := l.findActualUserDN(conn, username)
if err != nil {
actualUserDN = userDN // 回退到原始DN (Fallback to original DN)
}
// 尝试不同的常见组搜索模式 (Try different common group search patterns)
// 模式1:通过CN搜索组并检查成员属性 (Pattern 1: Search for group by CN and check member attribute)
groupFilter := fmt.Sprintf("(cn=%s)", groupName)
groupSearchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
groupFilter,
[]string{"member", "memberUid", "uniqueMember"},
nil,
)
sr, err := conn.Search(groupSearchRequest)
if err != nil {
log.Warn().Msgf("Group search failed: %v", err)
return false, err
}
for _, entry := range sr.Entries {
members := entry.GetAttributeValues("member")
memberUids := entry.GetAttributeValues("memberUid")
uniqueMembers := entry.GetAttributeValues("uniqueMember")
// 检查member属性(完整DN) (Check member attribute - full DN)
for _, member := range members {
if member == userDN || member == actualUserDN {
return true, nil
}
// 也检查是否member DN包含用户名 (Also check if member DN contains the username)
if strings.Contains(strings.ToLower(member), strings.ToLower("cn="+username)) {
return true, nil
}
}
// 检查memberUid属性(仅用户名) (Check memberUid attribute - username only)
for _, memberUid := range memberUids {
if memberUid == username {
return true, nil
}
}
// 检查uniqueMember属性(完整DN) (Check uniqueMember attribute - full DN)
for _, uniqueMember := range uniqueMembers {
if uniqueMember == userDN {
return true, nil
}
}
}
// 模式2:通过用户名搜索用户并检查memberOf属性 (Pattern 2: Search for user by username and check memberOf attribute)
// 使用配置的用户过滤器或默认的uid过滤器 (Use configured user filter or default uid filter)
userFilter := fmt.Sprintf(l.config.LdapUserFilter, username)
if l.config.LdapUserFilter == "" {
userFilter = fmt.Sprintf("(uid=%s)", username)
}
userSearchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
userFilter,
[]string{"memberOf", "distinguishedName"},
nil,
)
sr, err = conn.Search(userSearchRequest)
if err != nil {
log.Warn().Msgf("User search for memberOf failed: %v", err)
} else {
for _, entry := range sr.Entries {
memberOfValues := entry.GetAttributeValues("memberOf")
for _, memberOf := range memberOfValues {
if strings.Contains(strings.ToLower(memberOf), strings.ToLower("cn="+groupName)) {
return true, nil
}
}
}
}
return false, nil
}
// 查找用户的实际DN (Find the user's actual DN)
func (l *LDAPAuthenticator) findActualUserDN(conn *ldap.Conn, username string) (string, error) {
// 使用配置的用户过滤器搜索用户 (Search for user using configured user filter)
userFilter := fmt.Sprintf(l.config.LdapUserFilter, username)
if l.config.LdapUserFilter == "" {
userFilter = fmt.Sprintf("(uid=%s)", username)
}
userSearchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
userFilter,
[]string{"distinguishedName"},
nil,
)
sr, err := conn.Search(userSearchRequest)
if err != nil {
return "", err
}
if len(sr.Entries) == 0 {
return "", fmt.Errorf("user not found")
}
if len(sr.Entries) > 1 {
return "", fmt.Errorf("multiple users found")
}
return sr.Entries[0].DN, nil
}
// 执行用户认证,支持LDAP和传统密码认证 (Perform user authentication with LDAP and legacy password support)
func AuthenticateUser(cfg *Config, username, password, authMethod string) bool {
success, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
return success
}
// 执行用户认证并返回错误类型,支持LDAP和传统密码认证 (Perform user authentication with error type, supporting LDAP and legacy password authentication)
func AuthenticateUserWithError(cfg *Config, username, password, authMethod string) (bool, string) {
// 处理LDAP认证 (Handle LDAP authentication)
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
ldapAuth := NewLDAPAuthenticator(cfg)
success, err := ldapAuth.Authenticate(username, password)
if err != nil {
log.Error().Msgf("LDAP authentication error: %v", err)
// 检查错误类型以区分认证和授权错误 (Check error type to distinguish between authentication and authorization errors)
if strings.Contains(err.Error(), "user not authorized") {
return false, "authorization"
}
return false, "authentication"
}
return success, ""
}
// 回退到原始密码认证以保持向后兼容 (Fallback to original password authentication for backward compatibility)
if authMethod == "legacy" || authMethod == "" {
if cfg.Password == password {
return true, ""
}
return false, "authentication"
}
return false, "authentication"
}
+242 -143
View File
@@ -25,180 +25,279 @@
package main
import (
"context"
_ "net/http/pprof"
"os"
"runtime"
"runtime/debug"
"context"
"encoding/json"
_ "net/http/pprof"
"os"
"runtime"
"runtime/debug"
xlog "rttys/log"
xlog "rttys/log"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
)
const RttysVersion = "5.2.0"
var (
GitCommit = ""
BuildTime = ""
GitCommit = ""
BuildTime = ""
)
func main() {
defaultLogPath := "/var/log/rttys.log"
if runtime.GOOS == "windows" {
defaultLogPath = "rttys.log"
}
defaultLogPath := "/var/log/rttys.log"
if runtime.GOOS == "windows" {
defaultLogPath = "rttys.log"
}
cmd := &cli.Command{
Name: "rttys",
Usage: "The server side for rtty",
Version: RttysVersion,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "log",
Value: defaultLogPath,
Usage: "log file path",
},
&cli.StringFlag{
Name: "log-level",
Value: "info",
Usage: "log level(debug, info, warn, error)",
},
&cli.StringFlag{
Name: "conf",
Aliases: []string{"c"},
Usage: "config file to load",
},
&cli.StringFlag{
Name: "addr-dev",
Value: ":5912",
Usage: "address to listen device",
},
&cli.StringFlag{
Name: "addr-user",
Value: ":5913",
Usage: "address to listen user",
},
&cli.StringFlag{
Name: "addr-http-proxy",
Usage: "address to listen for HTTP proxy (default auto)",
},
&cli.StringFlag{
Name: "http-proxy-redir-url",
Usage: "url to redirect for HTTP proxy",
},
&cli.StringFlag{
Name: "http-proxy-redir-domain",
Usage: "domain for HTTP proxy set cookie",
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Usage: "token to use",
},
&cli.StringFlag{
Name: "dev-hook-url",
Usage: "called when the device is connected",
},
&cli.StringFlag{
Name: "user-hook-url",
Usage: "called when user accesses /connect/:devid, /cmd/:devid, /web/, or /web2/ APIs",
},
&cli.BoolFlag{
Name: "local-auth",
Value: true,
Usage: "need auth for local",
},
&cli.StringFlag{
Name: "password",
Usage: "web management password",
},
&cli.BoolFlag{
Name: "allow-origins",
Usage: "allow all origins for cross-domain request",
},
&cli.StringFlag{
Name: "pprof",
Usage: "enable pprof and listen on specified address (e.g. localhost:6060)",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"V"},
Usage: "more detailed output",
},
},
Action: cmdAction,
}
cmd := &cli.Command{
Name: "rttys",
Usage: "The server side for rtty",
Version: RttysVersion,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "log",
Value: defaultLogPath,
Usage: "log file path",
},
&cli.StringFlag{
Name: "log-level",
Value: "info",
Usage: "log level(debug, info, warn, error)",
},
&cli.StringFlag{
Name: "conf",
Aliases: []string{"c"},
Usage: "config file to load",
},
&cli.StringFlag{
Name: "addr-dev",
Value: ":5912",
Usage: "address to listen device",
},
&cli.StringFlag{
Name: "addr-user",
Value: ":5913",
Usage: "address to listen user",
},
&cli.StringFlag{
Name: "addr-http-proxy",
Usage: "address to listen for HTTP proxy (default auto)",
},
&cli.StringFlag{
Name: "http-proxy-redir-url",
Usage: "url to redirect for HTTP proxy",
},
&cli.StringFlag{
Name: "http-proxy-redir-domain",
Usage: "domain for HTTP proxy set cookie",
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Usage: "token to use",
},
&cli.StringFlag{
Name: "dev-hook-url",
Usage: "called when the device is connected",
},
&cli.StringFlag{
Name: "user-hook-url",
Usage: "called when user accesses /connect/:devid, /cmd/:devid, /web/, or /web2/ APIs",
},
&cli.BoolFlag{
Name: "local-auth",
Value: true,
Usage: "need auth for local",
},
&cli.StringFlag{
Name: "password",
Usage: "web management password",
},
&cli.BoolFlag{
Name: "allow-origins",
Usage: "allow all origins for cross-domain request",
},
&cli.BoolFlag{
Name: "ldap-enabled",
Usage: "enable LDAP authentication",
},
&cli.StringFlag{
Name: "ldap-server",
Usage: "LDAP server hostname or IP",
},
&cli.IntFlag{
Name: "ldap-port",
Value: 389,
Usage: "LDAP server port",
},
&cli.BoolFlag{
Name: "ldap-use-tls",
Usage: "use TLS/SSL for LDAP connection",
},
&cli.StringFlag{
Name: "ldap-bind-dn",
Usage: "LDAP bind DN for service account",
},
&cli.StringFlag{
Name: "ldap-bind-password",
Usage: "LDAP bind password for service account",
},
&cli.StringFlag{
Name: "ldap-base-dn",
Usage: "LDAP base DN for user searches",
},
&cli.StringFlag{
Name: "ldap-user-filter",
Value: "(uid=%s)",
Usage: "LDAP user filter",
},
&cli.StringFlag{
Name: "ldap-allowed-groups",
Usage: "comma-separated list of allowed LDAP groups",
},
&cli.StringFlag{
Name: "ldap-allowed-users",
Usage: "comma-separated list of allowed LDAP users",
},
&cli.StringFlag{
Name: "pprof",
Usage: "enable pprof and listen on specified address (e.g. localhost:6060)",
},
err := cmd.Run(context.Background(), os.Args)
if err != nil {
log.Fatal().Msg(err.Error())
}
// ---- OIDC Authentication (generic OIDC provider) ----
&cli.BoolFlag{
Name: "oidc-enabled",
Usage: "enable OIDC authentication (OpenID Connect)",
},
&cli.StringFlag{
Name: "oidc-generic-client-id",
Usage: "OIDC client ID (issued by the identity provider)",
},
&cli.StringFlag{
Name: "oidc-generic-client-secret",
Usage: "OIDC client secret (read from OIDC_GENERIC_CLIENT_SECRET env by default)",
},
&cli.StringFlag{
Name: "oidc-generic-auth-url",
Usage: "OIDC authorization endpoint URL",
},
&cli.StringFlag{
Name: "oidc-generic-token-url",
Usage: "OIDC token endpoint URL",
},
&cli.StringFlag{
Name: "oidc-generic-redirect-url",
Usage: "OIDC redirect/callback URL (must match one registered in IdP)",
},
&cli.StringFlag{
Name: "oidc-generic-scopes",
Value: "openid profile email",
Usage: "space-separated list of OIDC scopes",
},
&cli.StringFlag{
Name: "oidc-generic-allowed-users",
Usage: "optional email whitelist for OIDC logins (exact emails or @domain, space/comma-separated)",
},
&cli.StringFlag{
Name: "oidc-generic-allowed-subs",
Usage: "optional subject (sub) whitelist for OIDC logins (space/comma-separated)",
},
&cli.StringFlag{
Name: "oidc-generic-allowed-usernames",
Usage: "optional username whitelist for OIDC logins (preferred_username/name, space/comma-separated)",
},
&cli.StringFlag{
Name: "oidc-generic-allowed-groups",
Usage: "optional groups whitelist for OIDC logins (space/comma-separated)",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"V"},
Usage: "more detailed output",
},
},
Action: cmdAction,
}
err := cmd.Run(context.Background(), os.Args)
if err != nil {
log.Fatal().Msg(err.Error())
}
}
func cmdAction(c context.Context, cmd *cli.Command) error {
defer logPanic()
defer logPanic()
xlog.SetPath(cmd.String("log"))
xlog.SetPath(cmd.String("log"))
switch cmd.String("log-level") {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
switch cmd.String("log-level") {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
if cmd.Bool("verbose") {
xlog.Verbose()
}
if cmd.Bool("verbose") {
xlog.Verbose()
}
log.Info().Msg("Go Version: " + runtime.Version())
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
log.Info().Msg("Go Version: " + runtime.Version())
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
log.Info().Msg("Rttys Version: " + RttysVersion)
log.Info().Msg("Rttys Version: " + RttysVersion)
if GitCommit != "" {
log.Info().Msg("Git Commit: " + GitCommit)
}
if GitCommit != "" {
log.Info().Msg("Git Commit: " + GitCommit)
}
if BuildTime != "" {
log.Info().Msg("Build Time: " + BuildTime)
}
if BuildTime != "" {
log.Info().Msg("Build Time: " + BuildTime)
}
if runtime.GOOS != "windows" {
go signalHandle()
}
if runtime.GOOS != "windows" {
go signalHandle()
}
cfg := Config{
AddrDev: ":5912",
AddrUser: ":5913",
LocalAuth: true,
}
cfg := Config{
AddrDev: ":5912",
AddrUser: ":5913",
LocalAuth: true,
}
err := cfg.Parse(cmd)
if err != nil {
return err
}
err := cfg.Parse(cmd)
if err != nil {
return err
}
srv := &RttyServer{cfg: cfg}
// ===== 打印完整配置(验证配置是否加载正确) =====
{
importJSON, _ := json.MarshalIndent(cfg, "", " ")
log.Info().Msg("==== Loaded Configuration ====")
log.Info().Msg(string(importJSON))
log.Info().Msg("==============================")
}
return srv.Run()
srv := &RttyServer{cfg: cfg}
return srv.Run()
}
func logPanic() {
if r := recover(); r != nil {
saveCrashLog(r, debug.Stack())
os.Exit(2)
}
if r := recover(); r != nil {
saveCrashLog(r, debug.Stack())
os.Exit(2)
}
}
func saveCrashLog(p any, stack []byte) {
log.Error().Msgf("%v", p)
log.Error().Msg(string(stack))
log.Error().Msgf("%v", p)
log.Error().Msg(string(stack))
}
Executable
+476
View File
@@ -0,0 +1,476 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
oidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/fanjindong/go-cache"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/rs/zerolog/log"
"io"
"math/rand"
"net/http"
"net/url"
"rttys/utils"
"strings"
"time"
)
var (
// Session store for OAuth flow
oauthStore *sessions.CookieStore
// Global OIDC verifier
oidcVerifier *oidc.IDTokenVerifier
)
// Register OIDC routes
func RegisterOIDCRoutes(r *gin.Engine, cfg *Config) {
if !cfg.OIDCEnabled {
return
}
// ===== Initialize OIDC provider & ID token verifier =====
issuer := strings.TrimSpace(cfg.OIDCGenericIssuer)
if issuer == "" {
log.Error().Msg("OIDC is enabled but issuer (OIDCGenericIssuer) is empty")
return
}
ctx := context.Background()
//// Normalize issuer to always end with a single '/'
//issuer = strings.TrimRight(issuer, "/") + "/"
cfg.OIDCGenericIssuer = issuer
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
log.Error().Err(err).Msg("Failed to initialize OIDC provider")
return
}
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCGenericClientID,
// You can set SkipIssuerCheck, SkipClientIDCheck here if needed, but not recommended.
})
// Initialize session store
sessionSecret := generateRandomString(32)
oauthStore = sessions.NewCookieStore([]byte(sessionSecret))
// Configure session options
oauthStore.Options = &sessions.Options{
Path: "/",
MaxAge: 300, // 5 minutes, enough to complete the OAuth flow
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // Important: allow cookies on cross-site navigation (OIDC redirect)
Domain: "", // Empty means current host/domain will be used
}
// OIDC auth routes (public, no existing auth required)
r.GET("/auth/oidc/login", oidcLoginHandler(cfg))
r.GET("/auth/oidc/callback", oidcCallbackHandler(cfg))
}
// Start OIDC login
func oidcLoginHandler(cfg *Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Generate state and nonce
nonce := generateRandomString(32)
state := generateRandomString(32)
log.Info().Msgf("OIDC login initiated: nonce=%s, state=%s...", nonce[:10], state[:10])
// Save to session
session, _ := oauthStore.Get(c.Request, "oidc-session")
session.Values["state"] = state
session.Values["nonce"] = nonce
if err := session.Save(c.Request, c.Writer); err != nil {
log.Error().Err(err).Msg("Failed to save OIDC session")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
return
}
// Build authorization URL
params := url.Values{}
params.Add("client_id", cfg.OIDCGenericClientID)
params.Add("redirect_uri", cfg.OIDCGenericRedirectURL)
params.Add("response_type", "code")
params.Add("scope", strings.Join(cfg.OIDCGenericScopes, " "))
params.Add("state", state)
params.Add("nonce", nonce)
authURL := cfg.OIDCGenericAuthURL + "?" + params.Encode()
log.Info().Msgf("OIDC login redirect=%s", authURL[:100]+"...")
c.Redirect(http.StatusFound, authURL)
}
}
// Handle OIDC callback
func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Get session
session, err := oauthStore.Get(c.Request, "oidc-session")
if err != nil {
log.Error().Err(err).Msg("Failed to get OIDC session")
c.Redirect(http.StatusFound, "/?error=session_error")
return
}
// Validate state
state := c.Query("state")
savedState, ok := session.Values["state"].(string)
if !ok || state != savedState {
log.Warn().Msg("OIDC state mismatch")
c.Redirect(http.StatusFound, "/?error=invalid_state")
return
}
// Check OAuth error
if errorMsg := c.Query("error"); errorMsg != "" {
errorDesc := c.Query("error_description")
log.Warn().Msgf("OIDC error: %s - %s", errorMsg, errorDesc)
c.Redirect(http.StatusFound, "/?error="+errorMsg)
return
}
// Read authorization code
code := c.Query("code")
if code == "" {
c.Redirect(http.StatusFound, "/?error=no_code")
return
}
// Exchange authorization code for tokens
tokens, err := exchangeCodeForTokens(cfg, code)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange code for tokens")
c.Redirect(http.StatusFound, "/?error=token_exchange_failed")
return
}
// Extract user info
var userEmail string
var userName string
// Standard OIDC – verify and parse ID token
rawIDToken, ok := tokens["id_token"].(string)
if !ok {
log.Error().Msg("No ID token in response")
c.Redirect(http.StatusFound, "/?error=no_id_token")
return
}
if oidcVerifier == nil {
log.Error().Msg("OIDC verifier is not initialized")
c.Redirect(http.StatusFound, "/?error=server_config")
return
}
// ==== Signature + standard claims verification ====
idToken, err := oidcVerifier.Verify(c.Request.Context(), rawIDToken)
if err != nil {
log.Error().Err(err).Msg("Failed to verify ID token signature/claims")
c.Redirect(http.StatusFound, "/?error=invalid_token")
return
}
// Decode claims into a map
claims := map[string]interface{}{}
if err := idToken.Claims(&claims); err != nil {
log.Error().Err(err).Msg("Failed to parse ID token claims")
c.Redirect(http.StatusFound, "/?error=invalid_token")
return
}
// Pretty-print all claims as JSON for debugging
if claimsJSON, err := json.MarshalIndent(claims, "", " "); err == nil {
log.Info().Msg("========== OIDC ID Token Claims ==========")
log.Info().Msg(string(claimsJSON))
log.Info().Msg("==========================================")
} else {
log.Warn().Err(err).Msg("Failed to marshal OIDC claims to JSON")
}
// Validate nonce
if savedNonce, ok := session.Values["nonce"].(string); ok {
if claims["nonce"] != savedNonce {
log.Warn().Msg("OIDC nonce mismatch")
c.Redirect(http.StatusFound, "/?error=invalid_nonce")
return
}
}
sub, _ := claims["sub"].(string)
userEmail, _ = claims["email"].(string)
userName, _ = claims["name"].(string)
if userName == "" {
// Fallback: use email or sub as display name
if userEmail != "" {
userName = userEmail
} else {
userName = sub
}
}
// sub is required by OIDC spec and should never be empty
if sub == "" {
log.Error().Msg("OIDC token is missing 'sub' claim")
c.Redirect(http.StatusFound, "/?error=user_info_failed")
return
}
log.Info().Msgf("OIDC login successful: email=%s", userEmail)
// ====== OIDC whitelist enforcement ======
if !isOIDCUserAllowed(cfg, claims) {
log.Warn().Msgf("OIDC user not allowed by whitelist rules, sub=%v, email=%v", claims["sub"], claims["email"])
c.Redirect(http.StatusFound, "/?error=authorization")
return
}
// Create application session
sid := utils.GenUniqueID()
httpSessions.Set(sid, gin.H{
"email": userEmail,
"name": userName,
"oidc": true,
}, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", cfg.SslCert != "", true)
// Clean up OAuth session
session.Options.MaxAge = -1
session.Save(c.Request, c.Writer)
// Redirect to home page
c.Redirect(http.StatusFound, "/")
}
}
// Exchange authorization code for tokens
func exchangeCodeForTokens(cfg *Config, code string) (map[string]interface{}, error) {
data := url.Values{}
data.Set("code", code)
data.Set("client_id", cfg.OIDCGenericClientID)
data.Set("client_secret", cfg.OIDCGenericClientSecret)
data.Set("redirect_uri", cfg.OIDCGenericRedirectURL)
data.Set("grant_type", "authorization_code")
req, err := http.NewRequest("POST", cfg.OIDCGenericTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token request failed: %s", string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
// Generate a random string with given length
func generateRandomString(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)[:length]
}
func isOIDCUserAllowed(cfg *Config, claims map[string]interface{}) bool {
email, _ := claims["email"].(string)
sub, _ := claims["sub"].(string)
preferredUsername, _ := claims["preferred_username"].(string)
name, _ := claims["name"].(string)
// 1) Email whitelist
if len(cfg.OIDCGenericAllowedUsers) > 0 {
if !isEmailAllowed(cfg.OIDCGenericAllowedUsers, email) {
return false
}
}
// 2) Sub (subject) whitelist
if len(cfg.OIDCGenericAllowedSubs) > 0 {
if !contains(cfg.OIDCGenericAllowedSubs, sub) {
return false
}
}
// 3) Username whitelist (preferred_username > name)
if len(cfg.OIDCGenericAllowedUsernames) > 0 {
u := preferredUsername
if u == "" {
u = name
}
if !contains(cfg.OIDCGenericAllowedUsernames, u) {
return false
}
}
// 4) Groups whitelist
if len(cfg.OIDCGenericAllowedGroups) > 0 {
groups := extractStringSlice(claims["groups"])
if !intersects(groups, cfg.OIDCGenericAllowedGroups) {
return false
}
}
return true
}
// Email whitelist check rules:
// - Exact match (case-insensitive)
// - Simple domain match: entries starting with "@example.com" or "*@example.com"
// mean "allow all users under this domain"
func isEmailAllowed(allowed []string, email string) bool {
if len(allowed) == 0 {
return true
}
e := strings.ToLower(strings.TrimSpace(email))
for _, raw := range allowed {
a := strings.ToLower(strings.TrimSpace(raw))
if a == "" {
continue
}
if strings.HasPrefix(a, "*@") || strings.HasPrefix(a, "@") {
// Domain match
dom := strings.TrimPrefix(a, "*@")
dom = strings.TrimPrefix(dom, "@")
if strings.HasSuffix(e, "@"+dom) {
return true
}
continue
}
// Exact match
if e == a {
return true
}
}
return false
}
// contains reports whether slice contains the given value v.
// Comparison is done after trimming spaces on both sides.
func contains(slice []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, s := range slice {
if strings.TrimSpace(s) == v {
return true
}
}
return false
}
// extractStringSlice tries to normalize a generic claim value into a []string.
//
// It supports:
// - []string
// - []interface{} (only string elements are kept)
// - string (split by comma and/or whitespace)
// Any other type will result in an empty slice.
func extractStringSlice(v interface{}) []string {
if v == nil {
return nil
}
switch vv := v.(type) {
case []string:
// Return a shallow copy to avoid accidental modification.
out := make([]string, 0, len(vv))
for _, s := range vv {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
case []interface{}:
out := make([]string, 0, len(vv))
for _, item := range vv {
s, ok := item.(string)
if !ok {
continue
}
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
case string:
// Allow comma- or whitespace-separated group lists.
s := strings.TrimSpace(vv)
if s == "" {
return nil
}
// Replace commas with spaces, then split on whitespace.
s = strings.ReplaceAll(s, ",", " ")
parts := strings.Fields(s)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
default:
return nil
}
}
// intersects reports whether slice a and b share at least one common element.
// Matching is done after trimming spaces on both sides.
func intersects(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
m := make(map[string]struct{}, len(a))
for _, s := range a {
s = strings.TrimSpace(s)
if s == "" {
continue
}
m[s] = struct{}{}
}
for _, s := range b {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := m[s]; ok {
return true
}
}
return false
}
Vendored
+1 -1
View File
@@ -8,7 +8,7 @@
*/
/// <reference types="vite/client" />
import type { BaseButton, BaseText } from '@gl/main/components'
import type { BaseButton, BaseText } from 'gl-web-main/components'
import type BaseSvg from './src/components/base/baseSvg.vue'
declare module 'vue' {
+1 -1
View File
@@ -18,7 +18,6 @@
"prepare": "husky"
},
"dependencies": {
"@gl/main": "0.0.50",
"@vue/eslint-config-typescript": "12.0.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/addon-web-links": "0.11.0",
@@ -26,6 +25,7 @@
"ant-design-vue": "^4.2.6",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"gl-web-main": "^1.0.0",
"js-cookie": "^3.0.5",
"jsencrypt": "^3.3.2",
"pinia": "^3.0.2",
+2 -2
View File
@@ -27,8 +27,8 @@ import { computed } from 'vue'
import { useAppStore } from './stores/modules/app'
import { RouterView } from 'vue-router'
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
import { ConfigProvider as GlConfigProvider } from '@gl/main/components'
import { Languages } from '@gl/main'
import { ConfigProvider as GlConfigProvider } from 'gl-web-main/components'
import { Languages } from 'gl-web-main'
const appStore = useAppStore()
+4 -4
View File
@@ -1,15 +1,15 @@
/*
* @Author: LPY
* @Date: 2025-06-03 09:29:03
* @LastEditors: LPY
* @LastEditTime: 2025-08-25 19:23:29
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-27 03:16:38 EDT
* @FilePath: \glkvm-cloud\web-ui\src\api\request.ts
* @Description: 请求统一配置文件
*/
import { NotNeedHandledRequestErrorCodeList, RequestErrorCodeEnum } from '@/models/request'
import { useUserStore } from '@/stores/modules/user'
import { showErrorMessage } from './requestError'
import { BaseResponse, HttpService } from '@gl/main'
import { BaseResponse, HttpService } from 'gl-web-main'
import type { AxiosResponse } from 'axios'
export const httpService = new HttpService(
@@ -43,7 +43,7 @@ export const httpService = new HttpService(
},
error => {
console.log('请求错误', error)
Promise.reject(error)
return Promise.reject(error)
},
)
+10 -3
View File
@@ -1,14 +1,14 @@
/*
* @Author: LPY
* @Date: 2025-06-03 12:21:21
* @LastEditors: LPY
* @LastEditTime: 2025-08-26 09:06:31
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\web-ui\src\api\user.ts
* @Description: 用户相关请求api
*/
import request from './request'
import type { LoginParams } from '@/models/user'
import type { LoginParams, AuthConfig } from '@/models/user'
/** 登录 */
export function reqLogin (data: LoginParams) {
@@ -31,4 +31,11 @@ export function reqCheckLoginStatus () {
return request<void>({
url: '/alive',
})
}
/** 获取认证配置 */
export function reqAuthConfig () {
return request<AuthConfig>({
url: '/auth-config',
})
}
@@ -19,8 +19,8 @@
</template>
<script setup lang="ts" generic="T">
import type { SelectOptions } from '@gl/main'
import { BaseDivider, BaseDropdownSelect } from '@gl/main/components'
import type { SelectOptions } from 'gl-web-main'
import { BaseDivider, BaseDropdownSelect } from 'gl-web-main/components'
import type { Trigger } from 'ant-design-vue/es/dropdown/props'
import { computed } from 'vue'
+2 -2
View File
@@ -36,8 +36,8 @@
<script setup lang="ts" generic="T extends AnyObject">
import type { DropdownGroupItem } from '@/models/component'
import type { AnyObject } from '@gl/main'
import { BaseDivider } from '@gl/main/components'
import type { AnyObject } from 'gl-web-main'
import { BaseDivider } from 'gl-web-main/components'
import { Dropdown, Menu, Radio, RadioGroup } from 'ant-design-vue'
import type { Trigger } from 'ant-design-vue/es/dropdown/props'
import { computed, reactive, watch } from 'vue'
+1 -1
View File
@@ -46,7 +46,7 @@
</template>
<script setup lang="ts">
import { debounce } from '@gl/main'
import { debounce } from 'gl-web-main'
import { ref } from 'vue'
const props = withDefaults(defineProps<{
+1 -1
View File
@@ -24,7 +24,7 @@
<script setup lang="ts" generic="T extends BaseData" >
import type { BaseTableProps } from '@/models/component'
import { isEmpty, type BaseData } from '@gl/main'
import { isEmpty, type BaseData } from 'gl-web-main'
import { Table } from 'ant-design-vue'
import { useSlots } from 'vue'
+1 -1
View File
@@ -24,7 +24,7 @@
</template>
<script setup lang="ts">
import { ViewType } from '@gl/main'
import { ViewType } from 'gl-web-main'
import { Tooltip } from 'ant-design-vue'
const props = withDefaults(defineProps<{
+2 -2
View File
@@ -53,11 +53,11 @@
<script setup lang="ts">
import useLanguage from '@/hooks/useLanguage'
import { languageOptions, Languages } from '@gl/main'
import { languageOptions, Languages } from 'gl-web-main'
import BaseStep from './baseStep.vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { BaseDropdownSelect } from '@gl/main/components'
import { BaseDropdownSelect } from 'gl-web-main/components'
import { isForeignEnv } from '@/utils'
const route = useRoute()
+1 -1
View File
@@ -6,7 +6,7 @@
* @FilePath: \kvm-cloud-frontend\src\hooks\useDeviceQueue.ts
* @Description: 自动发现设备队列
*/
import { AnyObject, deepClone, isEmpty } from '@gl/main'
import { AnyObject, deepClone, isEmpty } from 'gl-web-main'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const DEFAULT_QUEUE_INTERVAL = 400
+1 -1
View File
@@ -11,7 +11,7 @@ import i18n from '@/lang'
import { languageLabelMap } from '@/models/setting'
import { computed } from 'vue'
import { LocalStorageKeys, useLocalStorage } from './useLocalStorage'
import { Languages } from '@gl/main'
import { Languages } from 'gl-web-main'
/** 语言hook */
export default function useLanguage () {
// @ts-ignore
+1 -1
View File
@@ -6,7 +6,7 @@
* @FilePath: \gl-cloud-frontend\src\hooks\usePageLink.js
* @Description: 用于处理分页相关的逻辑
*/
import type { AnyObject, TableDataResponse } from '@gl/main'
import type { AnyObject, TableDataResponse } from 'gl-web-main'
import { computed, ref } from 'vue'
export const GLOBAL_PAGE_SIZE = 'GLOBAL_PAGE_SIZE'
+1 -1
View File
@@ -8,7 +8,7 @@
*/
import { computed } from 'vue'
import useLanguage from './useLanguage'
import type { SelectOptions } from '@gl/main'
import type { SelectOptions } from 'gl-web-main'
export const useTranslatedOptions = <T>(options: SelectOptions<T>[]) => {
const { t } = useLanguage()
+1 -1
View File
@@ -10,7 +10,7 @@ import { createI18n } from 'vue-i18n'
import zh from './locales/zh.json'
import en from './locales/en.json'
import useLanguage from '@/hooks/useLanguage'
import { Languages } from '@gl/main'
import { Languages } from 'gl-web-main'
const i18n = createI18n({
legacy: false,
+8 -1
View File
@@ -18,11 +18,18 @@
},
"login": {
"authorizationRequired": "Authorization Required",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"enterPwdTip": "Please enter your password",
"incorrectPwd": "Incorrect Password",
"signOut": "Sign Out"
"notAuthorized": "Not Authorized",
"signOut": "Sign Out",
"authOptions": "Authentication Options",
"ldapAuth": "Enter username and password for LDAP authentication",
"webManagementAuth": "Leave username empty and use web management password",
"or": "OR",
"loginWithOidc": "Log in with OIDC"
},
"device": {
"devices": "Devices",
+8 -1
View File
@@ -18,11 +18,18 @@
},
"login": {
"authorizationRequired": "需要授权",
"username": "用户名",
"password": "密码",
"signIn": "登录",
"enterPwdTip": "请输入密码",
"incorrectPwd": "密码错误",
"signOut": "退出"
"notAuthorized": "未授权",
"signOut": "退出",
"authOptions": "认证方式",
"ldapAuth": "输入用户名和密码进行LDAP认证",
"webManagementAuth": "留空用户名并使用Web管理密码",
"or": "或",
"loginWithOidc": "使用OIDC登录"
},
"device": {
"devices": "设备数",
+1 -1
View File
@@ -8,7 +8,7 @@
*/
import { createApp } from 'vue'
import '@gl/main/style.css'
import 'gl-web-main/style.css'
import '@/styles/index.scss'
import App from './App.vue'
import projectInitialize from './projectInitialize'
+1 -1
View File
@@ -6,7 +6,7 @@
* @FilePath: \kvm-cloud-frontend\src\models\component.ts
* @Description: 组件有关的models
*/
import type { SelectOptions } from '@gl/main'
import type { SelectOptions } from 'gl-web-main'
import type { TableProps } from 'ant-design-vue'
export interface DropdownGroupItem<T = any> {
+1 -1
View File
@@ -7,7 +7,7 @@
* @Description: 设置相关类型声明
*/
import { Languages, SelectOptions } from '@gl/main'
import { Languages, SelectOptions } from 'gl-web-main'
/** 语言对应的label映射 */
export const languageLabelMap = new Map<Languages, string>([
+12 -3
View File
@@ -2,12 +2,21 @@
* @Author: LPY
* @Date: 2025-06-09 16:37:00
* @LastEditors: LPY
* @LastEditTime: 2025-08-22 11:19:48
* @FilePath: \glkvm-cloud\web-ui\src\models\user.ts
* @LastEditTime: 2025-11-12 16:19:19
* @FilePath: \glkvm-cloud\ui\src\models\user.ts
* @Description: 用户相关类型声明
*/
/** 登录参数 */
/** 登录参数 (Login parameters) */
export interface LoginParams {
username?: string;
password: string;
authMethod?: 'ldap' | 'legacy';
}
/** 认证配置 (Authentication configuration) */
export interface AuthConfig {
ldapEnabled: boolean;
legacyPassword: boolean;
oidcEnabled: boolean;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import type { App } from 'vue'
import '@/assets/iconfont/iconfont.js'
import BaseSvg from '@/components/base/baseSvg.vue'
import { BaseButton, BaseText } from '@gl/main/components'
import { BaseButton, BaseText } from 'gl-web-main/components'
/** 全局注册自定义组件 */
const installComponent = function (app: App) {
+3 -2
View File
@@ -2,11 +2,12 @@
* @Author: LPY
* @Date: 2025-05-30 09:54:42
* @LastEditors: LPY
* @LastEditTime: 2025-08-22 12:09:36
* @FilePath: \glkvm-cloud\web-ui\src\router\whiteList.ts
* @LastEditTime: 2025-11-12 16:36:23
* @FilePath: \glkvm-cloud\ui\src\router\whiteList.ts
* @Description: 路由白名单页
*/
export default [
'/login',
'/error',
'/auth/oidc/login',
]
+1 -1
View File
@@ -7,7 +7,7 @@
* @Description: app相关状态存储
*/
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
import { baseTheme, createStyleInsert, darkTheme, replaceAntTheme, ThemeMode } from '@gl/main'
import { baseTheme, createStyleInsert, darkTheme, replaceAntTheme, ThemeMode } from 'gl-web-main'
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
import { defineStore } from 'pinia'
import { computed, reactive } from 'vue'
+1 -1
View File
@@ -8,7 +8,7 @@
*/
import { getDeviceListApi } from '@/api/device'
import { type DeviceInfo, type DeviceQuery } from '@/models/device'
import { PageLink } from '@gl/main'
import { PageLink } from 'gl-web-main'
import { defineStore } from 'pinia'
import { computed, reactive, ref, watch } from 'vue'
+15 -4
View File
@@ -1,8 +1,8 @@
/*
* @Author: LPY
* @Date: 2025-05-29 18:43:46
* @LastEditors: LPY
* @LastEditTime: 2025-08-26 09:24:09
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\web-ui\src\stores\modules\user.ts
* @Description: 用户相关状态存储
*/
@@ -37,10 +37,19 @@ export const useUserStore = defineStore('user', () => {
/** 登录 */
const login = async (credentials: LoginParams) => {
// 加密密码
const params = {
// 准备登录参数 (Prepare login parameters)
const params: LoginParams = {
password: credentials.password,
}
// 如果提供了用户名和认证方法则添加 (Add username and auth method if provided)
if (credentials.username) {
params.username = credentials.username
}
if (credentials.authMethod) {
params.authMethod = credentials.authMethod
}
const data = await reqLogin(params)
console.log(data.info)
loginStatus.value = true
@@ -74,6 +83,8 @@ export const useUserStore = defineStore('user', () => {
logout()
} catch (error) {
console.log(error)
// 即使退出请求失败也继续本地退出 (Continue local logout even if logout request fails)
logout()
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
*/
import { t } from '@/hooks/useLanguage'
import { glConfirm, type BaseConfirmProps } from '@gl/main'
import { glConfirm, type BaseConfirmProps } from 'gl-web-main'
import { message } from 'ant-design-vue'
import { ValidateErrorEntity } from 'ant-design-vue/es/form/interface'
import { AxiosError } from 'axios'
@@ -31,9 +31,9 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { BaseModal } from '@gl/main/components'
import { BaseModal } from 'gl-web-main/components'
import { getAddDeviceScriptInfoApi } from '@/api/device'
import { copyText } from '@gl/main'
import { copyText } from 'gl-web-main'
import { message } from 'ant-design-vue'
import { t } from '@/hooks/useLanguage'
@@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { BaseModal } from '@gl/main/components'
import { BaseModal } from 'gl-web-main/components'
const props = defineProps<{ open: boolean, type: string, res: any }>()
@@ -79,8 +79,8 @@ import { reqExecuteCommand } from '@/api/device'
import BaseTable from '@/components/base/baseTable.vue'
import { t } from '@/hooks/useLanguage'
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
import { useFakeUpgradeProgress } from '@gl/main'
import { BaseModal } from '@gl/main/components'
import { useFakeUpgradeProgress } from 'gl-web-main'
import { BaseModal } from 'gl-web-main/components'
import { TableColumnType } from 'ant-design-vue'
import { computed, reactive, watch } from 'vue'
import CmdResDetailDialog from './cmdResDetailDialog.vue'
@@ -64,8 +64,8 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { BaseModal } from '@gl/main/components'
import { FormRules, OnBeforeOk, useValidateInfo } from '@gl/main'
import { BaseModal } from 'gl-web-main/components'
import { FormRules, OnBeforeOk, useValidateInfo } from 'gl-web-main'
import { t } from '@/hooks/useLanguage'
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
import { FormInstance } from 'ant-design-vue'
+1 -1
View File
@@ -44,7 +44,7 @@ import '@xterm/xterm/css/xterm.css'
import OverlayAddon from './components/xterm-addon-overlay'
import ContextMenu from './components/contextMenu.vue'
import RttyKeyboard from './components/rttyKeyboard.vue'
import { BaseModal } from '@gl/main/components'
import { BaseModal } from 'gl-web-main/components'
import { message, Modal } from 'ant-design-vue'
import { t } from '@/hooks/useLanguage'
+1 -1
View File
@@ -47,7 +47,7 @@ import NoDevicePage from './components/noDevicePage.vue'
import { useDeviceStore } from '@/stores/modules/device'
import DeviceListView from './components/deviceListView.vue'
import { reactive, onBeforeUnmount, onMounted } from 'vue'
import { BaseLoading } from '@gl/main/components'
import { BaseLoading } from 'gl-web-main/components'
import AddDeviceDialog from './components/addDeviceDialog.vue'
const deviceStore = useDeviceStore()
+205 -14
View File
@@ -2,16 +2,49 @@
* @Author: LPY
* @Date: 2025-05-30 10:48:43
* @LastEditors: LPY
* @LastEditTime: 2025-08-26 17:11:12
* @FilePath: \glkvm-cloud\web-ui\src\views\login\loginPage.vue
* @LastEditTime: 2025-11-12 17:01:59
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
* @Description: 登录页面
-->
<template>
<BaseWhitePage>
<LoginBox>
<BaseText type="large-title-m" style="margin: 24px 0 16px;">
{{ $t('login.authorizationRequired') }}
</BaseText>
<div style="display: flex; align-items: center; justify-content: center; margin: 24px 0 16px;">
<BaseText type="large-title-m">
{{ $t('login.authorizationRequired') }}
</BaseText>
<!-- 认证选项帮助 - 仅在启用LDAP时显示 (Auth options help - only show when LDAP is enabled) -->
<div v-if="isLdapEnabled" class="auth-help-container">
<div
class="help-icon"
@mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
style="margin-left: 8px; color: #656d76; cursor: help;"
>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343
1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0
.25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655
.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009
.388-1.009.40z"/>
</svg>
</div>
<!-- 提示框 (Tooltip) -->
<div v-show="showTooltip" class="auth-tooltip">
<div style="font-weight: bold; margin-bottom: 4px;">{{ $t('login.authOptions') }}:</div>
<div>• {{ $t('login.ldapAuth') }}</div>
<div>• {{ $t('login.webManagementAuth') }}</div>
</div>
</div>
</div>
<AForm
class="dense-form"
ref="formRef"
@@ -21,6 +54,16 @@
style="width: 100%;"
@validate="handleValidate"
>
<!-- 用户名字段 - 仅在启用LDAP时显示 (Username field - only show if LDAP is enabled) -->
<AFormItem v-if="isLdapEnabled" name="username">
<AInput
name="username"
v-model:value="state.formModel.username"
:placeholder="$t('login.username')"
@pressEnter="handleLogin"
/>
</AFormItem>
<AFormItem name="password">
<GlPassword
name="password"
@@ -34,6 +77,16 @@
<BaseButton medium type="primary" style="width: 100%;margin-top: 16px;" :loading="state.loading" @click="handleLogin">
{{ $t('login.signIn') }}
</BaseButton>
<div v-if="isOidcEnabled" class="google-login-box">
<a-divider style="border-color: var(--gl-color-line-divider1);color: var(--gl-color-text-level3);font-weight: normal;">
{{ $t('login.or') }}
</a-divider>
<BaseButton medium class="google-login-btn" :loading="state.oidcLoading" @click="handleLoginWithOidc">
<!-- <BaseSvg name="gl-icon-google" :size="20" style="margin-right: 10px;"/> -->
{{ $t('login.loginWithOidc') }}
</BaseButton>
</div>
</LoginBox>
</BaseWhitePage>
</template>
@@ -41,32 +94,71 @@
<script setup lang="ts">
import BaseWhitePage from '@/components/base/baseWhitePage.vue'
import LoginBox from './components/loginBox.vue'
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, onMounted } from 'vue'
import { t } from '@/hooks/useLanguage'
import { useUserStore } from '@/stores/modules/user'
import { useValidateInfo, type FormRules } from '@gl/main'
import { GlPassword } from '@gl/main/components'
import { useValidateInfo, type FormRules } from 'gl-web-main'
import { GlPassword } from 'gl-web-main/components'
import { useRouter } from 'vue-router'
import { LoginParams } from '@/models/user'
import { message } from 'ant-design-vue'
import { LoginParams, AuthConfig } from '@/models/user'
import { message, Input, Form } from 'ant-design-vue'
import { reqAuthConfig } from '@/api/user'
const AInput = Input
const AForm = Form
const AFormItem = Form.Item
const router = useRouter()
const { handleValidate } = useValidateInfo<LoginParams>()
const formRef = ref(null)
const authConfig = ref<AuthConfig | null>(null)
const state = reactive<{formModel: LoginParams, loading: boolean}>({
// 计算属性来可靠地检查LDAP是否启用 (Computed property to reliably check if LDAP is enabled)
const isLdapEnabled = computed(() => {
return authConfig.value?.ldapEnabled === true
})
// 计算是否允许OIDC认证
const isOidcEnabled = computed(() => {
return authConfig.value?.oidcEnabled === true
})
const state = reactive<{formModel: LoginParams, loading: boolean, oidcLoading: boolean}>({
formModel: {
username: '',
password: '',
},
loading: false,
oidcLoading: false,
})
const showTooltip = ref(false)
const formRules = computed<FormRules<LoginParams>>(() => {
return {
const rules: FormRules<LoginParams> = {
password: [{ required: true, message: 'login.enterPwdTip'}],
}
// 用户名为可选字段,支持双重认证模式 (Username is optional, supporting dual authentication modes)
return rules
})
// 加载认证配置 (Load authentication configuration)
onMounted(async () => {
try {
const response = await reqAuthConfig()
// 提取配置数据 (Extract config data)
const configData = response?.info || response?.data?.info || response?.data || response
authConfig.value = configData
} catch (error) {
console.error('Failed to load auth config:', error)
// 回退 - 无LDAP可用 (Fallback - no LDAP available)
authConfig.value = { ldapEnabled: false, legacyPassword: true, oidcEnabled: false }
}
})
// 登录按钮
@@ -74,7 +166,14 @@ const handleLogin = () => {
formRef.value.validate().then(async () => {
state.loading = true
try {
await useUserStore().login(state.formModel)
// 基于用户名自动确定认证方法 (Auto-determine auth method based on username)
const loginData: LoginParams = {
username: state.formModel.username,
password: state.formModel.password,
authMethod: (state.formModel.username && authConfig.value?.ldapEnabled) ? 'ldap' : 'legacy',
}
await useUserStore().login(loginData)
// 登录成功后跳转到首页或之前尝试访问的页面
const redirect = router.currentRoute.value.query.redirect as string || '/'
console.log(redirect)
@@ -84,11 +183,103 @@ const handleLogin = () => {
} catch (error) {
console.log(error)
state.loading = false
message.error(t('login.incorrectPwd'))
// 检查后端返回的错误类型 (Check error type returned by backend)
const errorData = error?.response?.data
const backendError = errorData?.error || ''
// 根据后端返回的具体错误类型显示不同消息 (Show different messages based on specific error type from backend)
if (backendError === 'user not authorized') {
// 授权失败 - 用户存在但权限不足 (Authorization failure - user exists but insufficient permissions)
message.error(t('login.notAuthorized'))
} else {
// 认证失败 - 用户名/密码错误 (Authentication failure - incorrect username/password)
message.error(t('login.incorrectPwd'))
}
}
})
}
// oidc登录按钮
const handleLoginWithOidc = () => {
state.oidcLoading = true
const baseUrl = window.location.origin
window.location.href = `${baseUrl}/auth/oidc/login`
}
</script>
<style scoped lang="scss">
.auth-help-container {
position: relative;
display: inline-block;
}
.help-icon {
display: inline-flex;
align-items: center;
transition: color 0.2s ease;
&:hover {
color: #0066cc !important;
}
}
.auth-tooltip {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
padding: 12px;
background-color: #2c3e50;
color: white;
border-radius: 6px;
font-size: 14px;
width: 320px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
// 添加箭头 (Add arrow)
&::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #2c3e50;
}
// 确保多行文本正确显示 (Ensure multi-line text displays correctly)
div {
white-space: normal;
line-height: 1.4;
&:not(:last-child) {
margin-bottom: 4px;
}
}
}
.google-login-box {
width: 100%;
.google-login-btn {
width: 100%;
color: var(--gl-color-text-google);
background-color: var(--gl-color-bg-google);
border-color: var(--gl-color-line-google);
font-size: 14px;
font-weight: 500;
height: 40px;
border-radius: 64px;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>
+12 -8
View File
@@ -23,35 +23,39 @@ export default defineConfig(({ mode }) => {
port: 3011,
proxy: {
'/devs': {
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/signin': {
target: 'https://49.7.174.146:1443/',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/signout': {
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/alive': {
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/get': {
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'^/cmd/.*': {
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'^/connect/.*': {
ws: true,
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
},
'^/web/*': {
target: 'https://49.7.174.146:1443',
target: 'https://oidc.clanxie.cn',
},
'/auth-config': {
target: 'https://oidc.clanxie.cn',
secure: false,
},
},
},
+553 -529
View File
File diff suppressed because it is too large Load Diff