8 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
19 changed files with 1235 additions and 398 deletions
+25 -1
View File
@@ -15,7 +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** - LDAP login support for enterprise users
- **Enterprise Authentication** - Supports both **LDAP** and **OIDC** login methods for enterprise users.
## Self-Hosting Guide
@@ -182,6 +182,30 @@ For detailed LDAP configuration options and setup instructions, see the [Docker
- **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:
+21 -1
View File
@@ -16,7 +16,7 @@
* **数据安全** - 私有化部署,数据完全可控
* **独享带宽** - 自部署环境下可享受专属带宽
* **轻量设计** - 专为小型企业和个人优化
* **企业级认证** - 支持 LDAP 登录,适用于企业用户
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
## 自部署指南
@@ -155,6 +155,7 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现有的目录服务(如 Active Directory、OpenLDAP 或 FreeIPA)集成。
**主要功能:**
- **双重认证模式**:同时支持 LDAP 和传统密码认证
- **基于组的授权**:限制特定 LDAP 组访问
- **基于用户的授权**:仅允许特定用户访问
@@ -168,6 +169,25 @@ GLKVM 轻量云支持 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 轻量云服务以应用更改:
+54 -51
View File
@@ -215,65 +215,68 @@ func (srv *RttyServer) ListenAPI() error {
c.Status(http.StatusOK)
})
r.POST("/signin", func(c *gin.Context) {
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod"`
}
r.POST("/signin", func(c *gin.Context) {
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod"`
}
creds := credentials{}
creds := credentials{}
err := c.BindJSON(&creds)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
err := c.BindJSON(&creds)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
// 自动确定认证方法或使用指定的方法 (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"
}
}
// 自动确定认证方法或使用指定的方法 (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
}
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
}
// 根据错误类型返回适当的错误信息 (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"})
}
})
// 根据错误类型返回适当的错误信息 (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 != "",
}
c.JSON(http.StatusOK, authConfig)
})
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) {
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.Status(http.StatusOK)
}
})
r.GET("/alive", func(c *gin.Context) {
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.Status(http.StatusOK)
}
})
// ===== 添加OIDC路由 =====
RegisterOIDCRoutes(r, cfg)
fs, err := fs.Sub(staticFs, "ui/dist")
if err != nil {
return err
+236 -136
View File
@@ -25,171 +25,271 @@
package main
import (
"fmt"
"os"
"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
// LDAP配置 (LDAP Configuration)
LdapEnabled bool
LdapServer string
LdapPort int
LdapUseTLS bool
LdapBindDN string
LdapBindPassword string
LdapBaseDN string
LdapUserFilter string
LdapAllowedGroups string
LdapAllowedUsers 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)
// LDAP配置标志 (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)
// 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)
return nil
// 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)
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)
// 注意:为了避免特殊字符解析问题和提高安全性,ldap-bind-password故意不从YAML读取
// 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密码始终从环境变量读取以避免YAML特殊字符解析问题和提高安全性
// 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
}
return nil
// 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
}
+25
View File
@@ -36,3 +36,28 @@ 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=
+24 -1
View File
@@ -20,7 +20,7 @@
- `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`)
@@ -35,6 +35,29 @@
⚠️ **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.
3. **Start the services**
```bash
+15
View File
@@ -34,6 +34,21 @@ services:
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
+4 -1
View File
@@ -65,7 +65,10 @@ case "$1" in
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
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
;;
@@ -26,3 +26,24 @@ 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}}
+5
View File
@@ -3,12 +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
@@ -27,10 +29,12 @@ require (
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
@@ -43,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
+8
View File
@@ -10,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=
@@ -29,6 +31,8 @@ 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=
@@ -47,7 +51,9 @@ 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=
@@ -144,6 +150,8 @@ 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=
+242 -185
View File
@@ -25,222 +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.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)",
},
&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
}
+3 -1
View File
@@ -27,7 +27,9 @@
"signOut": "Sign Out",
"authOptions": "Authentication Options",
"ldapAuth": "Enter username and password for LDAP authentication",
"webManagementAuth": "Leave username empty and use web management password"
"webManagementAuth": "Leave username empty and use web management password",
"or": "OR",
"loginWithOidc": "Log in with OIDC"
},
"device": {
"devices": "Devices",
+3 -1
View File
@@ -27,7 +27,9 @@
"signOut": "退出",
"authOptions": "认证方式",
"ldapAuth": "输入用户名和密码进行LDAP认证",
"webManagementAuth": "留空用户名并使用Web管理密码"
"webManagementAuth": "留空用户名并使用Web管理密码",
"or": "或",
"loginWithOidc": "使用OIDC登录"
},
"device": {
"devices": "设备数",
+4 -3
View File
@@ -1,9 +1,9 @@
/*
* @Author: LPY
* @Date: 2025-06-09 16:37:00
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\web-ui\src\models\user.ts
* @LastEditors: LPY
* @LastEditTime: 2025-11-12 16:19:19
* @FilePath: \glkvm-cloud\ui\src\models\user.ts
* @Description: 用户相关类型声明
*/
@@ -18,4 +18,5 @@ export interface LoginParams {
export interface AuthConfig {
ldapEnabled: boolean;
legacyPassword: boolean;
oidcEnabled: boolean;
}
+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',
]
+54 -7
View File
@@ -1,9 +1,9 @@
<!--
* @Author: LPY
* @Date: 2025-05-30 10:48:43
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\web-ui\src\views\login\loginPage.vue
* @LastEditors: LPY
* @LastEditTime: 2025-11-12 17:01:59
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
* @Description: 登录页面
-->
<template>
@@ -13,6 +13,7 @@
<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
@@ -28,7 +29,11 @@
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"/>
<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) -->
@@ -72,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>
@@ -105,12 +120,18 @@ const isLdapEnabled = computed(() => {
return authConfig.value?.ldapEnabled === true
})
const state = reactive<{formModel: LoginParams, loading: boolean}>({
// 计算是否允许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)
@@ -136,7 +157,7 @@ onMounted(async () => {
} catch (error) {
console.error('Failed to load auth config:', error)
// 回退 - 无LDAP可用 (Fallback - no LDAP available)
authConfig.value = { ldapEnabled: false, legacyPassword: true }
authConfig.value = { ldapEnabled: false, legacyPassword: true, oidcEnabled: false }
}
})
@@ -149,7 +170,7 @@ const handleLogin = () => {
const loginData: LoginParams = {
username: state.formModel.username,
password: state.formModel.password,
authMethod: (state.formModel.username && authConfig.value?.ldapEnabled) ? 'ldap' : 'legacy'
authMethod: (state.formModel.username && authConfig.value?.ldapEnabled) ? 'ldap' : 'legacy',
}
await useUserStore().login(loginData)
@@ -178,6 +199,13 @@ const handleLogin = () => {
}
})
}
// oidc登录按钮
const handleLoginWithOidc = () => {
state.oidcLoading = true
const baseUrl = window.location.origin
window.location.href = `${baseUrl}/auth/oidc/login`
}
</script>
<style scoped lang="scss">
@@ -235,4 +263,23 @@ const handleLogin = () => {
}
}
}
.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://106.55.158.199',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/signin': {
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/signout': {
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/alive': {
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'/get': {
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'^/cmd/.*': {
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
secure: false,
},
'^/connect/.*': {
ws: true,
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
},
'^/web/*': {
target: 'https://106.55.158.199',
target: 'https://oidc.clanxie.cn',
},
'/auth-config': {
target: 'https://oidc.clanxie.cn',
secure: false,
},
},
},