mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 20:54:47 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80112c7978 | |||
| b943a18959 | |||
| ea37a171e9 | |||
| 689793d47f | |||
| 55854afba8 | |||
| c8c67d5f0e | |||
| 66566e74a8 |
@@ -102,6 +102,13 @@ LDAP_USER_FILTER=(uid=%s)
|
||||
LDAP_ALLOWED_GROUPS=admins,operators
|
||||
LDAP_ALLOWED_USERS=user1,user2
|
||||
|
||||
# LDAP admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
|
||||
LDAP_ADMIN_GROUP=
|
||||
# LDAP admin users: these usernames are directly assigned the "admin" role.
|
||||
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
|
||||
LDAP_ADMIN_USERS=
|
||||
|
||||
# OIDC Authentication (Optional, generic OIDC provider)
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=
|
||||
@@ -126,3 +133,10 @@ OIDC_ALLOWED_SUBS=
|
||||
OIDC_ALLOWED_USERNAMES=
|
||||
# Groups whitelist (e.g. admin, devops)
|
||||
OIDC_ALLOWED_GROUPS=
|
||||
|
||||
# OIDC admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
|
||||
OIDC_ADMIN_GROUP=
|
||||
# OIDC admin users: these users are directly assigned the "admin" role.
|
||||
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
|
||||
OIDC_ADMIN_USERS=
|
||||
|
||||
@@ -63,7 +63,7 @@ DEVICE_ENDPOINT_HOST=
|
||||
# - Leave empty to disable domain restriction (allow access via any domain)
|
||||
WEB_UI_HOST=
|
||||
|
||||
GLKVM access IP seen by devices/users.
|
||||
# GLKVM access IP seen by devices/users.
|
||||
# Leave empty to auto-detect at container start.
|
||||
GLKVM_ACCESS_IP=
|
||||
|
||||
@@ -101,6 +101,13 @@ LDAP_USER_FILTER=(uid=%s)
|
||||
LDAP_ALLOWED_GROUPS=admins,operators
|
||||
LDAP_ALLOWED_USERS=user1,user2
|
||||
|
||||
# LDAP admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
|
||||
LDAP_ADMIN_GROUP=
|
||||
# LDAP admin users: these usernames are directly assigned the "admin" role.
|
||||
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
|
||||
LDAP_ADMIN_USERS=
|
||||
|
||||
# OIDC Authentication (Optional, generic OIDC provider)
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=
|
||||
@@ -125,3 +132,10 @@ OIDC_ALLOWED_SUBS=
|
||||
OIDC_ALLOWED_USERNAMES=
|
||||
# Groups whitelist (e.g. admin, devops)
|
||||
OIDC_ALLOWED_GROUPS=
|
||||
|
||||
# OIDC admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
|
||||
OIDC_ADMIN_GROUP=
|
||||
# OIDC admin users: these users are directly assigned the "admin" role.
|
||||
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
|
||||
OIDC_ADMIN_USERS=
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "2.0"
|
||||
|
||||
services:
|
||||
rttys:
|
||||
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
|
||||
@@ -35,6 +33,8 @@ services:
|
||||
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
|
||||
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
|
||||
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
|
||||
LDAP_ADMIN_GROUP: ${LDAP_ADMIN_GROUP:-}
|
||||
LDAP_ADMIN_USERS: ${LDAP_ADMIN_USERS:-}
|
||||
|
||||
# ---- OIDC Authentication ----
|
||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||
@@ -50,6 +50,8 @@ services:
|
||||
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
|
||||
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
|
||||
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
|
||||
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
|
||||
OIDC_ADMIN_USERS: ${OIDC_ADMIN_USERS:-}
|
||||
|
||||
# ---- Reverse Proxy ----
|
||||
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
|
||||
|
||||
@@ -66,9 +66,11 @@ case "$1" in
|
||||
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_ADMIN_GROUP LDAP_ADMIN_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
|
||||
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS \
|
||||
OIDC_ADMIN_GROUP OIDC_ADMIN_USERS
|
||||
|
||||
exec rttys -c /home/rttys.conf
|
||||
;;
|
||||
|
||||
@@ -29,6 +29,8 @@ ldap-base-dn: {{LDAP_BASE_DN}}
|
||||
ldap-user-filter: {{LDAP_USER_FILTER}}
|
||||
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
|
||||
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
|
||||
ldap-admin-group: {{LDAP_ADMIN_GROUP}}
|
||||
ldap-admin-users: {{LDAP_ADMIN_USERS}}
|
||||
|
||||
# OIDC Authentication (generic OIDC provider)
|
||||
oidc-enabled: {{OIDC_ENABLED}}
|
||||
@@ -50,3 +52,5 @@ 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}}
|
||||
oidc-admin-group: {{OIDC_ADMIN_GROUP}}
|
||||
oidc-admin-users: {{OIDC_ADMIN_USERS}}
|
||||
|
||||
@@ -20,4 +20,6 @@ type User struct {
|
||||
Role identity.Role
|
||||
Status Status
|
||||
IsSystem bool
|
||||
AuthProvider string // "local", "oidc", "ldap"
|
||||
ExternalSub string // OIDC sub claim / LDAP user DN
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "context"
|
||||
type Repository interface {
|
||||
FindByID(ctx context.Context, id int64) (*User, error)
|
||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||
FindByExternalID(ctx context.Context, provider, externalSub string) (*User, error)
|
||||
FindSystemAdmin(ctx context.Context) (*User, error)
|
||||
|
||||
Create(ctx context.Context, u *User) (int64, error)
|
||||
|
||||
@@ -3,8 +3,9 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"rttys/internal/domain/identity"
|
||||
"strconv"
|
||||
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/pkg/password"
|
||||
)
|
||||
|
||||
@@ -108,3 +109,90 @@ func (s *Service) UpdateUser(ctx context.Context, id int64, username, descriptio
|
||||
func (s *Service) DeleteUser(ctx context.Context, id int64) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// FindOrCreateExternalUser looks up a user by (provider, externalSub).
|
||||
// If found, it updates email/description and returns the user.
|
||||
// If not found, it creates a new user with the given role and status=active.
|
||||
//
|
||||
// role is determined by the caller based on admin-group/admin-users membership
|
||||
// and is only applied at user creation time. Existing users keep their current role.
|
||||
func (s *Service) FindOrCreateExternalUser(ctx context.Context, provider, externalSub, preferredUsername, email, displayName string, role identity.Role) (*User, error) {
|
||||
u, err := s.repo.FindByExternalID(ctx, provider, externalSub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u != nil {
|
||||
// Update email and display name on each login (IdP may change them).
|
||||
changed := false
|
||||
if email != "" && u.Email != email {
|
||||
u.Email = email
|
||||
changed = true
|
||||
}
|
||||
if displayName != "" && u.Description != displayName {
|
||||
u.Description = displayName
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = s.repo.Update(ctx, u)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// --- Create new user ---
|
||||
username := s.pickUniqueUsername(ctx, preferredUsername, email, provider)
|
||||
|
||||
newUser := &User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Description: displayName,
|
||||
PasswordHash: "", // external users never authenticate via password
|
||||
Role: role,
|
||||
Status: StatusActive,
|
||||
AuthProvider: provider,
|
||||
ExternalSub: externalSub,
|
||||
}
|
||||
id, err := s.repo.Create(ctx, newUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newUser.ID = id
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
// pickUniqueUsername tries candidate usernames until one doesn't conflict.
|
||||
func (s *Service) pickUniqueUsername(ctx context.Context, preferredUsername, email, provider string) string {
|
||||
candidates := make([]string, 0, 4)
|
||||
if preferredUsername != "" {
|
||||
candidates = append(candidates, preferredUsername)
|
||||
}
|
||||
if email != "" && email != preferredUsername {
|
||||
candidates = append(candidates, email)
|
||||
}
|
||||
// Fallback with provider suffix
|
||||
if preferredUsername != "" {
|
||||
candidates = append(candidates, preferredUsername+"_"+provider)
|
||||
}
|
||||
if email != "" {
|
||||
candidates = append(candidates, email+"_"+provider)
|
||||
}
|
||||
// Last resort
|
||||
if len(candidates) == 0 {
|
||||
candidates = append(candidates, provider+"_user")
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
existing, _ := s.repo.FindByUsername(ctx, c)
|
||||
if existing == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
// All candidates taken — append a numeric suffix
|
||||
base := candidates[0] + "_" + provider
|
||||
for i := 2; ; i++ {
|
||||
name := base + "_" + strconv.Itoa(i)
|
||||
existing, _ := s.repo.FindByUsername(ctx, name)
|
||||
if existing == nil {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package dto
|
||||
|
||||
type MeUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
}
|
||||
|
||||
type MeResp struct {
|
||||
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
Description string `json:"description"`
|
||||
Role string `json:"role"`
|
||||
IsSystem bool `json:"isSystem"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
UserGroupList []UserGroupRef `json:"userGroupList"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/pkg/ldap"
|
||||
"rttys/xconfig"
|
||||
"strings"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"rttys/internal/store/memory"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
@@ -40,7 +42,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
// ---- LDAP ----
|
||||
authMethod := req.AuthMethod
|
||||
if authMethod == "ldap" {
|
||||
ok, errorType := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
|
||||
ok, errorType, userDN, isAdmin := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
|
||||
if !ok {
|
||||
if errorType == "authorization" {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "User not authorized", nil))
|
||||
@@ -49,12 +51,22 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
sysAdmin, err := h.userSvc.GetSystemAdmin(c.Request.Context())
|
||||
role := identity.RoleUser
|
||||
if isAdmin {
|
||||
role = identity.RoleAdmin
|
||||
}
|
||||
ldapUser, err := h.userSvc.FindOrCreateExternalUser(c.Request.Context(), "ldap", userDN, req.Username, "", req.Username, role)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "System admin not found", nil))
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to create LDAP user", nil))
|
||||
return
|
||||
}
|
||||
userID = sysAdmin.ID
|
||||
log.Info().
|
||||
Str("username", req.Username).
|
||||
Str("userDN", userDN).
|
||||
Str("role", string(role)).
|
||||
Int64("userID", ldapUser.ID).
|
||||
Msg("LDAP user login completed")
|
||||
userID = ldapUser.ID
|
||||
} else {
|
||||
u, err := h.userSvc.Authenticate(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil || u == nil {
|
||||
|
||||
@@ -168,6 +168,15 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
cmp = strings.Compare(items[i].Description, items[j].Description)
|
||||
case "ddns":
|
||||
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
|
||||
case "deviceGroupName":
|
||||
var gi, gj string
|
||||
if items[i].DeviceGroupID != nil {
|
||||
gi = groupNameByID[*items[i].DeviceGroupID]
|
||||
}
|
||||
if items[j].DeviceGroupID != nil {
|
||||
gj = groupNameByID[*items[j].DeviceGroupID]
|
||||
}
|
||||
cmp = strings.Compare(gi, gj)
|
||||
default:
|
||||
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ func (h *MeHandler) GetMe(c *gin.Context) {
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, dto.MeResp{
|
||||
User: dto.MeUser{
|
||||
ID: p.UserID,
|
||||
Username: p.Username,
|
||||
DisplayName: p.DisplayName,
|
||||
Role: string(p.Role),
|
||||
ID: p.UserID,
|
||||
Username: p.Username,
|
||||
DisplayName: p.DisplayName,
|
||||
Role: string(p.Role),
|
||||
AuthProvider: p.AuthProvider,
|
||||
},
|
||||
Permissions: p.PermissionKeys,
|
||||
}))
|
||||
|
||||
@@ -88,6 +88,7 @@ func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
Username: u.Username,
|
||||
Description: u.Description,
|
||||
IsSystem: u.IsSystem,
|
||||
AuthProvider: u.AuthProvider,
|
||||
UserGroupList: groups,
|
||||
})
|
||||
}
|
||||
@@ -161,6 +162,8 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
target, err := h.userSvc.FindByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
|
||||
@@ -172,6 +175,16 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
req.Password = nil
|
||||
req.Repassword = nil
|
||||
}
|
||||
// Users cannot change their own role
|
||||
if id == p.UserID {
|
||||
req.Role = nil
|
||||
}
|
||||
// External users (OIDC/LDAP): username and password are managed by the IdP
|
||||
if target.AuthProvider != "" && target.AuthProvider != "local" {
|
||||
req.Username = nil
|
||||
req.Password = nil
|
||||
req.Repassword = nil
|
||||
}
|
||||
|
||||
if err := h.userSvc.UpdateUser(c.Request.Context(), id, req.Username, req.Description, req.Password, req.Role, nil); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
|
||||
@@ -20,6 +20,7 @@ type Principal struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role identity.Role `json:"role"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
PermissionKeys []string `json:"permissions"`
|
||||
}
|
||||
|
||||
@@ -82,11 +83,17 @@ func Auth(sessionStore *memory.SessionStore, userSvc *user.Service, permSvc *per
|
||||
displayName = u.Username
|
||||
}
|
||||
|
||||
authProvider := u.AuthProvider
|
||||
if authProvider == "" {
|
||||
authProvider = "local"
|
||||
}
|
||||
|
||||
c.Set(PrincipalKey, Principal{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: displayName,
|
||||
Role: u.Role,
|
||||
AuthProvider: authProvider,
|
||||
PermissionKeys: perms,
|
||||
})
|
||||
|
||||
|
||||
+72
-22
@@ -31,59 +31,71 @@ func NewLDAPAuthenticator(config *xconfig.Config) *LDAPAuthenticator {
|
||||
}
|
||||
|
||||
// 执行用户LDAP认证 (Perform LDAP authentication for a user)
|
||||
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, error) {
|
||||
// Returns (success, userDN, isAdmin, error). userDN is the distinguished name of the authenticated user.
|
||||
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, string, bool, error) {
|
||||
if !l.config.LdapEnabled {
|
||||
return false, fmt.Errorf("LDAP authentication is disabled")
|
||||
return false, "", false, fmt.Errorf("LDAP authentication is disabled")
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
return false, fmt.Errorf("username and password are required")
|
||||
return false, "", 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)
|
||||
return false, "", 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 == "")
|
||||
return false, "", 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)
|
||||
return false, "", 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)
|
||||
return false, "", 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)
|
||||
return false, "", 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)
|
||||
return false, "", 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)
|
||||
return false, "", false, fmt.Errorf("authorization check failed: %v", err)
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
return false, fmt.Errorf("user not authorized")
|
||||
return false, "", false, fmt.Errorf("user not authorized")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
// 检查用户是否为管理员 (Check if user is admin by group or username)
|
||||
isAdmin := l.checkIsAdmin(conn, userDN, username)
|
||||
|
||||
log.Info().
|
||||
Str("username", username).
|
||||
Str("userDN", userDN).
|
||||
Str("adminGroup", l.config.LdapAdminGroup).
|
||||
Str("adminUsers", l.config.LdapAdminUsers).
|
||||
Bool("isAdmin", isAdmin).
|
||||
Msg("LDAP authentication successful")
|
||||
|
||||
return true, userDN, isAdmin, nil
|
||||
}
|
||||
|
||||
// 建立到LDAP服务器的连接 (Establish connection to LDAP server)
|
||||
@@ -342,35 +354,73 @@ func (l *LDAPAuthenticator) findActualUserDN(conn *ldap.Conn, username string) (
|
||||
return sr.Entries[0].DN, nil
|
||||
}
|
||||
|
||||
// checkIsAdmin checks whether the authenticated user should be assigned the admin role,
|
||||
// by matching against LdapAdminUsers (username list) OR LdapAdminGroup (group membership).
|
||||
func (l *LDAPAuthenticator) checkIsAdmin(conn *ldap.Conn, userDN, username string) bool {
|
||||
// 1) Check admin users list
|
||||
adminUsers := strings.TrimSpace(l.config.LdapAdminUsers)
|
||||
if adminUsers != "" {
|
||||
users := strings.Split(adminUsers, ",")
|
||||
for _, u := range users {
|
||||
if strings.TrimSpace(u) == username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Check admin group membership
|
||||
adminGroups := strings.TrimSpace(l.config.LdapAdminGroup)
|
||||
if adminGroups != "" {
|
||||
groups := strings.Split(adminGroups, ",")
|
||||
for _, group := range groups {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
isMember, err := l.isGroupMember(conn, userDN, username, group)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("Error checking admin group membership for %s in %s: %v", username, group, err)
|
||||
continue
|
||||
}
|
||||
if isMember {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 执行用户认证,支持LDAP和传统密码认证 (Perform user authentication with LDAP and legacy password support)
|
||||
func AuthenticateUser(cfg *xconfig.Config, username, password, authMethod string) bool {
|
||||
success, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
|
||||
success, _, _, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
|
||||
return success
|
||||
}
|
||||
|
||||
// 执行用户认证并返回错误类型,支持LDAP和传统密码认证 (Perform user authentication with error type, supporting LDAP and legacy password authentication)
|
||||
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string) {
|
||||
// AuthenticateUserWithError performs authentication and returns (success, errorType, userDN, isAdmin).
|
||||
// userDN and isAdmin are only populated for successful LDAP authentication.
|
||||
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string, string, bool) {
|
||||
// 处理LDAP认证 (Handle LDAP authentication)
|
||||
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
|
||||
ldapAuth := NewLDAPAuthenticator(cfg)
|
||||
success, err := ldapAuth.Authenticate(username, password)
|
||||
success, userDN, isAdmin, 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, "authorization", "", false
|
||||
}
|
||||
return false, "authentication"
|
||||
return false, "authentication", "", false
|
||||
}
|
||||
return success, ""
|
||||
return success, "", userDN, isAdmin
|
||||
}
|
||||
|
||||
if authMethod == "legacy" || authMethod == "" {
|
||||
if cfg.Password == password {
|
||||
return true, ""
|
||||
return true, "", "", false
|
||||
}
|
||||
return false, "authentication"
|
||||
return false, "authentication", "", false
|
||||
}
|
||||
|
||||
return false, "authentication"
|
||||
return false, "authentication", "", false
|
||||
}
|
||||
|
||||
+44
-5
@@ -13,6 +13,7 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/pkg/randtoken"
|
||||
"rttys/xconfig"
|
||||
@@ -232,21 +233,59 @@ func oidcCallbackHandler(cfg *xconfig.Config, userSvc *user.Service) gin.Handler
|
||||
return
|
||||
}
|
||||
|
||||
// ==== Create application session (new session_store, same as LDAP) ====
|
||||
sid, err := randtoken.New() // randtoken.New()
|
||||
// ==== Create application session ====
|
||||
sid, err := randtoken.New()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create session token")
|
||||
c.Redirect(http.StatusFound, "/?error=internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
sysAdmin, err := userSvc.GetSystemAdmin(c.Request.Context())
|
||||
preferredUsername, _ := claims["preferred_username"].(string)
|
||||
|
||||
// Determine role based on admin group / admin users
|
||||
role := identity.RoleUser
|
||||
hasAdminRule := len(cfg.OIDCAdminGroup) > 0 || len(cfg.OIDCAdminUsers) > 0
|
||||
if hasAdminRule {
|
||||
// Check admin users list (match preferred_username or email)
|
||||
if len(cfg.OIDCAdminUsers) > 0 {
|
||||
if contains(cfg.OIDCAdminUsers, preferredUsername) || contains(cfg.OIDCAdminUsers, userEmail) {
|
||||
role = identity.RoleAdmin
|
||||
}
|
||||
}
|
||||
// Check admin group membership
|
||||
if role != identity.RoleAdmin && len(cfg.OIDCAdminGroup) > 0 {
|
||||
groups := extractStringSlice(claims["groups"])
|
||||
if intersects(groups, cfg.OIDCAdminGroup) {
|
||||
role = identity.RoleAdmin
|
||||
}
|
||||
}
|
||||
log.Info().
|
||||
Str("sub", sub).
|
||||
Str("email", userEmail).
|
||||
Str("name", userName).
|
||||
Str("preferredUsername", preferredUsername).
|
||||
Strs("userGroups", extractStringSlice(claims["groups"])).
|
||||
Strs("adminGroup", cfg.OIDCAdminGroup).
|
||||
Strs("adminUsers", cfg.OIDCAdminUsers).
|
||||
Str("role", string(role)).
|
||||
Msg("OIDC admin role check")
|
||||
}
|
||||
|
||||
oidcUser, err := userSvc.FindOrCreateExternalUser(c.Request.Context(), "oidc", sub, preferredUsername, userEmail, userName, role)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to find system admin user")
|
||||
log.Error().Err(err).Msg("Failed to find or create OIDC user")
|
||||
c.Redirect(http.StatusFound, "/?error=internal_error")
|
||||
return
|
||||
}
|
||||
sessionStore.Create(sid, sysAdmin.ID)
|
||||
log.Info().
|
||||
Str("sub", sub).
|
||||
Str("email", userEmail).
|
||||
Str("preferredUsername", preferredUsername).
|
||||
Str("role", string(role)).
|
||||
Int64("userID", oidcUser.ID).
|
||||
Msg("OIDC user login completed")
|
||||
sessionStore.Create(sid, oidcUser.ID)
|
||||
|
||||
c.SetCookie("sid", sid, 0, "/", "", cfg.SslCert != "", false)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
const KVMCloudVersion = "v2.3.0"
|
||||
const KVMCloudVersion = "v2.4.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
|
||||
@@ -81,7 +81,16 @@ func InitSchema(ctx context.Context, db *sql.DB, schemaPath string) error {
|
||||
if err := ensureDeviceClientColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureUserIsSystemColumn(ctx, db)
|
||||
if err := ensureUserIsSystemColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureAuthProviderColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureExternalSubColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureExternalIdentityIndex(ctx, db)
|
||||
}
|
||||
|
||||
func ensureDeviceClientColumn(ctx context.Context, db *sql.DB) error {
|
||||
@@ -111,3 +120,42 @@ func ensureUserIsSystemColumn(ctx context.Context, db *sql.DB) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureAuthProviderColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureExternalSubColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN external_sub TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureExternalIdentityIndex(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_identity
|
||||
ON users(auth_provider, external_sub)
|
||||
WHERE external_sub != ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ type userRow struct {
|
||||
Role string `gorm:"column:role"`
|
||||
Status string `gorm:"column:status"`
|
||||
IsSystem bool `gorm:"column:is_system"`
|
||||
AuthProvider string `gorm:"column:auth_provider"`
|
||||
ExternalSub string `gorm:"column:external_sub"`
|
||||
}
|
||||
|
||||
func (userRow) TableName() string { return "users" }
|
||||
@@ -51,6 +53,8 @@ func (r *UserRepo) FindByID(ctx context.Context, id int64) (*user.User, error) {
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
AuthProvider: row.AuthProvider,
|
||||
ExternalSub: row.ExternalSub,
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -77,6 +81,35 @@ func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*user.U
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
AuthProvider: row.AuthProvider,
|
||||
ExternalSub: row.ExternalSub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) FindByExternalID(ctx context.Context, provider, externalSub string) (*user.User, error) {
|
||||
var row userRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("auth_provider = ? AND external_sub = ?", provider, externalSub).
|
||||
Take(&row).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user.User{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Email: row.Email,
|
||||
Description: row.Description,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
AuthProvider: row.AuthProvider,
|
||||
ExternalSub: row.ExternalSub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -102,6 +135,8 @@ func (r *UserRepo) FindSystemAdmin(ctx context.Context) (*user.User, error) {
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
AuthProvider: row.AuthProvider,
|
||||
ExternalSub: row.ExternalSub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -122,6 +157,8 @@ func (r *UserRepo) List(ctx context.Context) ([]user.User, error) {
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
AuthProvider: row.AuthProvider,
|
||||
ExternalSub: row.ExternalSub,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -136,6 +173,8 @@ func (r *UserRepo) Create(ctx context.Context, u *user.User) (int64, error) {
|
||||
Role: string(u.Role),
|
||||
Status: string(u.Status),
|
||||
IsSystem: u.IsSystem,
|
||||
AuthProvider: u.AuthProvider,
|
||||
ExternalSub: u.ExternalSub,
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
@@ -157,6 +196,8 @@ func (r *UserRepo) Update(ctx context.Context, u *user.User) error {
|
||||
"role": string(u.Role),
|
||||
"status": string(u.Status),
|
||||
"is_system": u.IsSystem,
|
||||
"auth_provider": u.AuthProvider,
|
||||
"external_sub": u.ExternalSub,
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:18:18
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-03-10 11:52:27
|
||||
* @LastEditTime: 2026-03-25 11:06:30
|
||||
* @FilePath: \glkvm-cloud\ui\src\hooks\useLocalStorage.ts
|
||||
* @Description: 存储hook
|
||||
*/
|
||||
@@ -10,6 +10,8 @@ import { ref } from 'vue'
|
||||
|
||||
/** 整个系统 */
|
||||
export enum LocalStorageKeys {
|
||||
/** 当前系统版本 */
|
||||
APP_VERSION_KEY = 'app_version',
|
||||
/** 存储语言的key */
|
||||
STORAGE_LANGUAGE_KEY = 'language',
|
||||
/** 主题色 */
|
||||
@@ -22,6 +24,8 @@ export enum LocalStorageKeys {
|
||||
VERSION = 'version',
|
||||
/** 设备列表列表顺序 */
|
||||
DEVICE_LIST_COLUMNS_KEY = 'device-list-columns',
|
||||
/** 设备列表排序 */
|
||||
DEVICE_LIST_SORT_KEY = 'device-list-sort',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
"loginWithOidc": "Log in with OIDC",
|
||||
"confirmPasswordValidateError": "The passwords you typed do not match.",
|
||||
"accountLogin": "Account Login",
|
||||
"ldap": "LDAP"
|
||||
"ldap": "LDAP",
|
||||
"local": "Local",
|
||||
"oidc": "OIDC"
|
||||
},
|
||||
"device": {
|
||||
"devices": "Devices",
|
||||
@@ -158,7 +160,8 @@
|
||||
"deleteUserGroupConfirmTips3": "Are you sure you want to delete it?",
|
||||
"deleteOnlyOneAdminTips": "Cannot delete: Only system admin left.",
|
||||
"myGroup": "My Group",
|
||||
"userRoleDesc": "Administrators can view all devices, while ordinary users can only see the devices in the device group associated with the user group"
|
||||
"userRoleDesc": "Administrators can view all devices, while ordinary users can only see the devices in the device group associated with the user group",
|
||||
"userType": "User Type"
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "Requesting device to create terminal...",
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
"loginWithOidc": "使用OIDC登录",
|
||||
"confirmPasswordValidateError": "密码不一致。",
|
||||
"accountLogin": "账号登录",
|
||||
"ldap": "LDAP"
|
||||
"ldap": "LDAP",
|
||||
"local": "本地",
|
||||
"oidc": "OIDC"
|
||||
},
|
||||
"device": {
|
||||
"devices": "设备数",
|
||||
@@ -158,7 +160,8 @@
|
||||
"deleteUserGroupConfirmTips3": "你确定要删除它吗?",
|
||||
"deleteOnlyOneAdminTips": "无法删除:只剩一个系统管理员了。",
|
||||
"myGroup": "我的用户组",
|
||||
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备"
|
||||
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备",
|
||||
"userType": "用户类型"
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-02 15:13:17
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-09 09:13:22
|
||||
* @LastEditTime: 2026-03-25 10:09:43
|
||||
* @FilePath: \glkvm-cloud\ui\src\models\userManage.ts
|
||||
* @Description: 用户管理相关类型声明
|
||||
*/
|
||||
@@ -22,12 +22,25 @@ export const UserRoleLabelMap = new Map([
|
||||
[UserRoleEnum.USER, 'user.user'],
|
||||
])
|
||||
|
||||
export enum AuthProviderEnum {
|
||||
LOCAL = 'local',
|
||||
LDAP = 'ldap',
|
||||
OIDC = 'oidc',
|
||||
}
|
||||
|
||||
export const AuthProviderLabelMap = new Map([
|
||||
[AuthProviderEnum.LOCAL, 'login.local'],
|
||||
[AuthProviderEnum.LDAP, 'login.ldap'],
|
||||
[AuthProviderEnum.OIDC, 'login.oidc'],
|
||||
])
|
||||
|
||||
export interface UserManage {
|
||||
id: number
|
||||
username: string
|
||||
role: UserRoleEnum
|
||||
description: string
|
||||
isSystem: boolean
|
||||
authProvider: AuthProviderEnum,
|
||||
userGroupList: {
|
||||
userGroupId: number
|
||||
userGroupName: string
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 09:44:40
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-06-19 10:12:11
|
||||
* @FilePath: /kvm-cloud-frontend/src/projectInitialize/index.ts
|
||||
* @LastEditTime: 2026-03-25 11:09:30
|
||||
* @FilePath: \glkvm-cloud\ui\src\projectInitialize\index.ts
|
||||
* @Description: 项目初始化的操作
|
||||
*/
|
||||
import type { App } from 'vue'
|
||||
@@ -12,6 +12,7 @@ import { initializeAllLanguage } from '@/lang'
|
||||
import { installComponent } from './installComponent'
|
||||
import loadAdvComponent from './loadAdvComponent'
|
||||
import { installDirective } from './installDirective'
|
||||
import { checkAndClearCache } from '@/utils/versionManager'
|
||||
|
||||
export default function (app: App ) {
|
||||
/** 加载插件 */
|
||||
@@ -28,4 +29,7 @@ export default function (app: App ) {
|
||||
|
||||
/** 初始化语言 */
|
||||
initializeAllLanguage()
|
||||
|
||||
/** 检查并清理缓存 */
|
||||
checkAndClearCache()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* @Author: LPY
|
||||
* @Date: 2026-03-25 11:01:34
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-03-25 11:15:12
|
||||
* @FilePath: \glkvm-cloud\ui\src\utils\versionManager.ts
|
||||
* @Description: 版本管理工具,主要用于清理缓存
|
||||
*/
|
||||
|
||||
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
|
||||
|
||||
const APP_VERSION = '2.4.0' // 当前应用版本
|
||||
const CACHE_KEYS_TO_CLEAR = [LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY] // 需要清理的缓存key
|
||||
|
||||
export function checkAndClearCache () {
|
||||
const cachedVersion = useLocalStorage(LocalStorageKeys.APP_VERSION_KEY).getValue()
|
||||
|
||||
if (cachedVersion !== APP_VERSION) {
|
||||
// 版本不一致,清理指定缓存
|
||||
CACHE_KEYS_TO_CLEAR.forEach(key => {
|
||||
useLocalStorage(key).removeValue()
|
||||
})
|
||||
|
||||
// 更新版本号
|
||||
useLocalStorage(LocalStorageKeys.APP_VERSION_KEY).setValue(APP_VERSION)
|
||||
|
||||
console.log(`缓存已清理,版本从 ${cachedVersion} 升级到 ${APP_VERSION}`)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-08-25 09:32:42
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-11 09:31:33
|
||||
* @LastEditTime: 2026-03-25 10:11:49
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\device\components\addDeviceDialog.vue
|
||||
* @Description: 添加设备弹窗
|
||||
-->
|
||||
@@ -65,8 +65,8 @@ const OperatingSystemTranslated = computed(() => {
|
||||
return useTranslatedOptions([
|
||||
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.GL_KVM), value: OperatingSystemEnum.GL_KVM },
|
||||
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.LINUX), value: OperatingSystemEnum.LINUX },
|
||||
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.WINDOWS), value: OperatingSystemEnum.WINDOWS },
|
||||
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.MAC_OS), value: OperatingSystemEnum.MAC_OS },
|
||||
// { label: operatingSystemLabelMap.get(OperatingSystemEnum.WINDOWS), value: OperatingSystemEnum.WINDOWS },
|
||||
// { label: operatingSystemLabelMap.get(OperatingSystemEnum.MAC_OS), value: OperatingSystemEnum.MAC_OS },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: shufei.han
|
||||
* @Date: 2025-06-11 12:04:48
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-03-10 11:47:57
|
||||
* @LastEditTime: 2026-03-25 10:54:42
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\device\components\deviceListView.vue
|
||||
* @Description:
|
||||
-->
|
||||
@@ -206,7 +206,9 @@ const deviceColumns = ref<TableColumnType[]>([
|
||||
{title: t('device.connectedTime'), dataIndex: 'connectedTime', key: 'connectedTime', ellipsis: true,
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', key: 'deviceGroupName', ellipsis: true, width: 190},
|
||||
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', key: 'deviceGroupName', ellipsis: true, width: 190,
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('device.description'), dataIndex: 'description', key: 'description',
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
@@ -249,9 +251,17 @@ const onSelectChange = (selectedRowKeys: Key[], selectedRows: DeviceInfo[]) => {
|
||||
|
||||
const tableChange: TableProps['onChange'] = (pagination, filters, sorter: any) => {
|
||||
console.log('params', pagination, filters, sorter)
|
||||
if (sorter) {
|
||||
if (sorter?.order) {
|
||||
deviceStore.state.sortBy = sorter.field as string
|
||||
deviceStore.state.order = sorter.order === 'ascend' ? 'asc' : 'desc'
|
||||
useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).setValue({
|
||||
sortBy: deviceStore.state.sortBy,
|
||||
order: deviceStore.state.order,
|
||||
})
|
||||
} else {
|
||||
deviceStore.state.sortBy = undefined
|
||||
deviceStore.state.order = undefined
|
||||
useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).removeValue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +452,13 @@ const init = () => {
|
||||
})
|
||||
deviceColumns.value = parsedColumns.filter(col => (col as any).show)
|
||||
}
|
||||
|
||||
const sortKey = useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).getValue() as { sortBy: string, order: string }
|
||||
if (sortKey) {
|
||||
deviceStore.state.sortBy = sortKey.sortBy
|
||||
deviceStore.state.order = sortKey.order
|
||||
deviceColumns.value.find(col => col.key === sortKey.sortBy).defaultSortOrder = sortKey.order === 'asc' ? 'ascend' : 'descend'
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-03 11:24:20
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-28 09:51:05
|
||||
* @LastEditTime: 2026-03-25 10:19:00
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\userManage\components\editUserDialog.vue
|
||||
* @Description: 编辑用户弹窗
|
||||
-->
|
||||
@@ -48,7 +48,8 @@
|
||||
v-model:value="state.formData.username"
|
||||
:maxlength="32"
|
||||
:placeholder="$t('device.requiredDeviceGroupName')"
|
||||
:disabled="props.currentUser?.isSystem"
|
||||
:disabled="props.currentUser?.isSystem ||
|
||||
props.currentUser?.authProvider == AuthProviderEnum.LDAP || props.currentUser?.authProvider == AuthProviderEnum.OIDC"
|
||||
style="width: 100%;" />
|
||||
</AFormItem>
|
||||
<AFormItem name="description" :label="$t('device.description')" labelAlign="left">
|
||||
@@ -100,7 +101,7 @@ import { FormRules, OnBeforeOk } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { FormInstance, Tooltip } from 'ant-design-vue'
|
||||
import { reqUserGroupListOptions } from '@/api/deviceGroup'
|
||||
import { UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
|
||||
import { AuthProviderEnum, UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
|
||||
import { reqEditUser } from '@/api/userManage'
|
||||
import AddUserGroupDialog from './addUserGroupDialog.vue'
|
||||
import { useUserManageStore } from '@/stores/modules/userManage'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-02 14:32:56
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-06 18:00:36
|
||||
* @LastEditTime: 2026-03-25 10:10:53
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\userManage\userManagePage.vue
|
||||
* @Description: 用户管理页
|
||||
-->
|
||||
@@ -50,6 +50,9 @@
|
||||
style="background-color: var(--gl-color-warning-primary);color: var(--gl-color-warning-background);"
|
||||
>{{ $t(UserRoleLabelMap.get(record.role)) }}</BaseTag>
|
||||
</template>
|
||||
<template #authProvider="{ record }">
|
||||
{{ $t(AuthProviderLabelMap.get(record.authProvider || AuthProviderEnum.LOCAL)) }}
|
||||
</template>
|
||||
<template #userGroupList="{ record }">
|
||||
<div class="groups-a">
|
||||
<a
|
||||
@@ -131,7 +134,7 @@ import BaseLoadingContainer from '@/components/base/baseLoadingContainer.vue'
|
||||
import BasePagination from '@/components/base/basePagination.vue'
|
||||
import BaseTable from '@/components/base/baseTable.vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
|
||||
import { AuthProviderEnum, AuthProviderLabelMap, UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
|
||||
import { useUserManageStore } from '@/stores/modules/userManage'
|
||||
import { message, TableColumnType, Tooltip } from 'ant-design-vue'
|
||||
import { baseCustomModal, SelectOptions } from 'gl-web-main'
|
||||
@@ -156,6 +159,7 @@ const userColumns = computed<TableColumnType[]>(() => {
|
||||
return [
|
||||
{title: t('user.userName'), dataIndex: 'username', ellipsis: true},
|
||||
{title: t('user.role'), dataIndex: 'role', ellipsis: true},
|
||||
{title: t('user.userType'), dataIndex: 'authProvider'},
|
||||
{title: t('device.description'), dataIndex: 'description'},
|
||||
{title: t('device.associatedUserGroups'), dataIndex: 'userGroupList', ellipsis: true},
|
||||
{title: t('common.action'), dataIndex: 'action', width: 270},
|
||||
|
||||
+9
-9
@@ -23,39 +23,39 @@ export default defineConfig(({ mode }) => {
|
||||
port: 3011,
|
||||
proxy: {
|
||||
'/devs': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
},
|
||||
'/api': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/signout': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
},
|
||||
'/alive': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
},
|
||||
'/get': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
},
|
||||
'^/cmd/.*': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
},
|
||||
'^/connect/.*': {
|
||||
ws: true,
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
},
|
||||
'^/web/*': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
},
|
||||
'/auth-config': {
|
||||
target: 'https://107.173.152.173',
|
||||
target: 'https://106.55.158.199/',
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
@@ -70,6 +70,8 @@ type Config struct {
|
||||
LdapUserFilter string
|
||||
LdapAllowedGroups string
|
||||
LdapAllowedUsers string
|
||||
LdapAdminGroup string
|
||||
LdapAdminUsers string
|
||||
|
||||
// Generic OIDC Provider (supports any standard OIDC provider)
|
||||
OIDCEnabled bool
|
||||
@@ -84,6 +86,8 @@ type Config struct {
|
||||
OIDCGenericAllowedSubs []string
|
||||
OIDCGenericAllowedUsernames []string
|
||||
OIDCGenericAllowedGroups []string
|
||||
OIDCAdminGroup []string
|
||||
OIDCAdminUsers []string
|
||||
|
||||
// =====================================================
|
||||
// Reverse Proxy / Proxy Mode
|
||||
@@ -195,6 +199,8 @@ func parseYamlCfg(cfg *Config, conf string) error {
|
||||
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
|
||||
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
|
||||
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
|
||||
getConfigOpt(yamlCfg, "ldap-admin-group", &cfg.LdapAdminGroup)
|
||||
getConfigOpt(yamlCfg, "ldap-admin-users", &cfg.LdapAdminUsers)
|
||||
|
||||
// ===== OIDC configuration (generic OIDC provider) =====
|
||||
// Switch and basic endpoints
|
||||
@@ -236,6 +242,16 @@ func parseYamlCfg(cfg *Config, conf string) error {
|
||||
cfg.OIDCGenericAllowedGroups = splitScopes(s)
|
||||
}
|
||||
|
||||
// OIDC admin group
|
||||
if s, err := yamlCfg.Get("oidc-admin-group"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCAdminGroup = splitScopes(s)
|
||||
}
|
||||
|
||||
// OIDC admin users (preferred_username / email whitelist for admin role)
|
||||
if s, err := yamlCfg.Get("oidc-admin-users"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCAdminUsers = splitScopes(s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,6 +290,22 @@ func applyEnvCfg(cfg *Config) error {
|
||||
cfg.LdapBindPassword = envPassword
|
||||
}
|
||||
|
||||
// LDAP admin group / admin users
|
||||
if v := strings.TrimSpace(os.Getenv("LDAP_ADMIN_GROUP")); v != "" {
|
||||
cfg.LdapAdminGroup = v
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("LDAP_ADMIN_USERS")); v != "" {
|
||||
cfg.LdapAdminUsers = v
|
||||
}
|
||||
|
||||
// OIDC admin group / admin users
|
||||
if v := strings.TrimSpace(os.Getenv("OIDC_ADMIN_GROUP")); v != "" {
|
||||
cfg.OIDCAdminGroup = splitScopes(v)
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("OIDC_ADMIN_USERS")); v != "" {
|
||||
cfg.OIDCAdminUsers = splitScopes(v)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user