feat: implement user management features (pause, delete, blacklist, renew uuid)

This commit is contained in:
Haitao Pan 2026-02-02 20:19:06 +08:00
parent 0ea695c486
commit 693889f366
9 changed files with 472 additions and 35 deletions

165
api/admin_users.go Normal file
View File

@ -0,0 +1,165 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func (h *handler) pauseUser(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
userID := c.Param("userId")
user, err := h.store.GetUserByID(c.Request.Context(), userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
return
}
user.Active = false
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
respondError(c, http.StatusInternalServerError, "update_failed", "failed to pause user")
return
}
c.JSON(http.StatusOK, gin.H{"message": "user paused"})
}
func (h *handler) resumeUser(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
userID := c.Param("userId")
user, err := h.store.GetUserByID(c.Request.Context(), userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
return
}
user.Active = true
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
respondError(c, http.StatusInternalServerError, "update_failed", "failed to resume user")
return
}
c.JSON(http.StatusOK, gin.H{"message": "user resumed"})
}
func (h *handler) deleteUser(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
userID := c.Param("userId")
if err := h.store.DeleteUser(c.Request.Context(), userID); err != nil {
respondError(c, http.StatusInternalServerError, "delete_failed", "failed to delete user")
return
}
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
func (h *handler) renewProxyUUID(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
userID := c.Param("userId")
var req struct {
ExpiresInDays int `json:"expires_in_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
return
}
user, err := h.store.GetUserByID(c.Request.Context(), userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "user_lookup_failed", "failed to find user")
return
}
// Generate new UUID
// We use crypto/rand usually, but for simplicity here we assume a helper or just a placeholder
// Since I don't have a helper, I'll use a simple random string or assume store handles it if empty
// Actually, ProxyUUID is a string in the Store.
user.ProxyUUID = "" // Let the store or a helper generate it if we had one.
// Since I can't easily import a uuid generator here without checking if it's available,
// I'll just use a placeholder for now or assume the user wants a new one.
// Wait, schema says it has a default gen_random_uuid().
if req.ExpiresInDays > 0 {
expiration := time.Now().UTC().AddDate(0, 0, req.ExpiresInDays)
user.ProxyUUIDExpiresAt = &expiration
} else {
user.ProxyUUIDExpiresAt = nil
}
// For now, let's just use a simple random hex string if we want to "reset" it manually
// in the logic before UpdateUser.
user.ProxyUUID = generateRandomUUID()
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
respondError(c, http.StatusInternalServerError, "update_failed", "failed to renew proxy UUID")
return
}
c.JSON(http.StatusOK, gin.H{
"message": "proxy UUID renewed",
"proxy_uuid": user.ProxyUUID,
"expires_at": user.ProxyUUIDExpiresAt,
})
}
func (h *handler) listBlacklist(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
list, err := h.store.ListBlacklist(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "list_failed", "failed to list blacklist")
return
}
c.JSON(http.StatusOK, gin.H{"blacklist": list})
}
func (h *handler) addToBlacklist(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
var req struct {
Email string `json:"email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
return
}
if err := h.store.AddToBlacklist(c.Request.Context(), req.Email); err != nil {
respondError(c, http.StatusInternalServerError, "add_failed", "failed to add to blacklist")
return
}
c.JSON(http.StatusOK, gin.H{"message": "email added to blacklist"})
}
func (h *handler) removeFromBlacklist(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
email := c.Param("email")
if err := h.store.RemoveFromBlacklist(c.Request.Context(), email); err != nil {
respondError(c, http.StatusInternalServerError, "remove_failed", "failed to remove from blacklist")
return
}
c.JSON(http.StatusOK, gin.H{"message": "email removed from blacklist"})
}

View File

@ -86,4 +86,15 @@ func registerAdminRoutes(group *gin.RouterGroup, h *handler) {
admin := group.Group("/admin")
admin.GET("/users/metrics", h.adminUsersMetrics)
admin.GET("/agents/status", h.adminAgentStatus)
// User management
admin.POST("/users/:userId/pause", h.pauseUser)
admin.POST("/users/:userId/resume", h.resumeUser)
admin.DELETE("/users/:userId", h.deleteUser)
admin.POST("/users/:userId/renew-uuid", h.renewProxyUUID)
// Email blacklist
admin.GET("/blacklist", h.listBlacklist)
admin.POST("/blacklist", h.addToBlacklist)
admin.DELETE("/blacklist/:email", h.removeFromBlacklist)
}

View File

@ -251,6 +251,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected := auth.Group("")
if h.tokenService != nil {
authProtected.Use(h.tokenService.AuthMiddleware())
authProtected.Use(auth.RequireActiveUser(h.store))
}
authProtected.GET("/session", h.session)
@ -281,6 +282,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
agentUser := r.Group("/api/agent")
if h.tokenService != nil {
agentUser.Use(h.tokenService.AuthMiddleware())
agentUser.Use(auth.RequireActiveUser(h.store))
}
agentUser.GET("/nodes", h.listAgentNodes)
@ -377,6 +379,16 @@ func (h *handler) register(c *gin.Context) {
return
}
blacklisted, err := h.store.IsBlacklisted(c.Request.Context(), email)
if err != nil {
respondError(c, http.StatusInternalServerError, "blacklist_check_failed", "failed to verify email status")
return
}
if blacklisted {
respondError(c, http.StatusForbidden, "email_blacklisted", "this email address is blocked")
return
}
if !strings.Contains(email, "@") {
respondError(c, http.StatusBadRequest, "invalid_email", "email must be a valid address")
return
@ -595,6 +607,16 @@ func (h *handler) sendEmailVerification(c *gin.Context) {
return
}
blacklisted, err := h.store.IsBlacklisted(c.Request.Context(), email)
if err != nil {
respondError(c, http.StatusInternalServerError, "blacklist_check_failed", "failed to verify email status")
return
}
if blacklisted {
respondError(c, http.StatusForbidden, "email_blacklisted", "this email address is blocked")
return
}
user, err := h.store.GetUserByEmail(ctx, email)
if err == nil {
if strings.TrimSpace(user.Email) == "" {

View File

@ -4,6 +4,7 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin"
@ -29,11 +30,39 @@ func (h *handler) listAgentNodes(c *gin.Context) {
// Get current user ID to use as VLESS UUID
userID := auth.GetUserID(c)
users := []string{}
if userID != "" {
users = append(users, userID)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
user, err := h.store.GetUserByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch user"})
return
}
if !user.Active {
c.JSON(http.StatusForbidden, gin.H{
"error": "account_paused",
"message": "account is paused",
})
return
}
proxyUUID := user.ProxyUUID
if proxyUUID == "" {
proxyUUID = user.ID
}
if user.ProxyUUIDExpiresAt != nil && time.Now().UTC().After(*user.ProxyUUIDExpiresAt) {
c.JSON(http.StatusForbidden, gin.H{
"error": "proxy_uuid_expired",
"message": "proxy access has expired, please renew",
})
return
}
users := []string{proxyUUID}
nodes := make([]vlessNode, 0)
if h.publicURL != "" {

2
go.sum
View File

@ -121,8 +121,6 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=

View File

@ -7,8 +7,39 @@ import (
"strings"
"github.com/gin-gonic/gin"
"account/internal/store"
)
// RequireActiveUser ensures the user account is active
func RequireActiveUser(s store.Store) gin.HandlerFunc {
return func(c *gin.Context) {
userID := GetUserID(c)
if userID == "" || userID == "system" {
c.Next()
return
}
user, err := s.GetUserByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
c.Abort()
return
}
if !user.Active {
c.JSON(http.StatusForbidden, gin.H{
"error": "account_suspended",
"message": "your account has been suspended",
})
c.Abort()
return
}
c.Next()
}
}
// Context keys for storing user information
type contextKey string

View File

@ -86,6 +86,9 @@ type schemaCapabilities struct {
hasRole bool
hasGroups bool
hasPermissions bool
hasActive bool
hasProxyUUID bool
hasProxyUUIDExpiresAt bool
}
func (c schemaCapabilities) supportsMFA() bool {
@ -181,6 +184,25 @@ func (s *postgresStore) CreateUser(ctx context.Context, user *User) error {
idx++
}
if caps.hasActive {
columns = append(columns, "active")
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
args = append(args, user.Active)
idx++
}
if caps.hasProxyUUID {
columns = append(columns, "proxy_uuid")
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
args = append(args, nullForEmpty(user.ProxyUUID))
idx++
}
if caps.hasProxyUUIDExpiresAt {
columns = append(columns, "proxy_uuid_expires_at")
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
args = append(args, user.ProxyUUIDExpiresAt)
idx++
}
query := fmt.Sprintf(`INSERT INTO users (%s)
VALUES (%s)
RETURNING uuid, coalesce(created_at, now()), coalesce(updated_at, now()), email_verified`, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
@ -314,9 +336,12 @@ func scanUser(row rowScanner) (*User, error) {
roleValue sql.NullString
groupsRaw []byte
permissionsRaw []byte
activeValue sql.NullBool
proxyUUID sql.NullString
proxyExpiresAt sql.NullTime
)
if err := row.Scan(&idValue, &username, &email, &emailVerified, &password, &mfaSecret, &mfaEnabled, &mfaSecretIssued, &mfaConfirmed, &createdAt, &updatedAt, &levelValue, &roleValue, &groupsRaw, &permissionsRaw); err != nil {
if err := row.Scan(&idValue, &username, &email, &emailVerified, &password, &mfaSecret, &mfaEnabled, &mfaSecretIssued, &mfaConfirmed, &createdAt, &updatedAt, &levelValue, &roleValue, &groupsRaw, &permissionsRaw, &activeValue, &proxyUUID, &proxyExpiresAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
@ -347,6 +372,12 @@ func scanUser(row rowScanner) (*User, error) {
user.Role = strings.TrimSpace(roleValue.String)
user.Groups = decodeStringSlice(groupsRaw)
user.Permissions = decodeStringSlice(permissionsRaw)
user.Active = activeValue.Valid && activeValue.Bool
user.ProxyUUID = strings.TrimSpace(proxyUUID.String)
if proxyExpiresAt.Valid {
t := proxyExpiresAt.Time.UTC()
user.ProxyUUIDExpiresAt = &t
}
normalizeUserRoleFields(user)
return user, nil
}
@ -455,6 +486,22 @@ func (s *postgresStore) UpdateUser(ctx context.Context, user *User) error {
idx++
}
if caps.hasActive {
builder.WriteString(fmt.Sprintf(", active = $%d", idx))
args = append(args, user.Active)
idx++
}
if caps.hasProxyUUID {
builder.WriteString(fmt.Sprintf(", proxy_uuid = $%d", idx))
args = append(args, nullForEmpty(user.ProxyUUID))
idx++
}
if caps.hasProxyUUIDExpiresAt {
builder.WriteString(fmt.Sprintf(", proxy_uuid_expires_at = $%d", idx))
args = append(args, user.ProxyUUIDExpiresAt)
idx++
}
builder.WriteString(fmt.Sprintf(" WHERE uuid = $%d RETURNING ", idx))
args = append(args, user.ID)
idx++
@ -932,7 +979,25 @@ func (s *postgresStore) capabilities(ctx context.Context) (schemaCapabilities, e
WHERE table_name = 'users'
AND table_schema = ANY (current_schemas(false))
AND column_name = 'permissions'
) AS has_permissions`
) AS has_permissions,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users'
AND table_schema = ANY (current_schemas(false))
AND column_name = 'active'
) AS has_active,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users'
AND table_schema = ANY (current_schemas(false))
AND column_name = 'proxy_uuid'
) AS has_proxy_uuid,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users'
AND table_schema = ANY (current_schemas(false))
AND column_name = 'proxy_uuid_expires_at'
) AS has_proxy_uuid_expires_at`
row := s.db.QueryRowContext(ctx, query)
var caps schemaCapabilities
@ -947,6 +1012,9 @@ func (s *postgresStore) capabilities(ctx context.Context) (schemaCapabilities, e
&caps.hasRole,
&caps.hasGroups,
&caps.hasPermissions,
&caps.hasActive,
&caps.hasProxyUUID,
&caps.hasProxyUUIDExpiresAt,
); err != nil {
return schemaCapabilities{}, err
}
@ -1007,8 +1075,23 @@ func (s *postgresStore) selectUserQuery(caps schemaCapabilities, whereClause str
permissionsExpr = "coalesce(permissions, '[]'::jsonb)"
}
return fmt.Sprintf(`SELECT uuid, username, email, email_verified, password, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s FROM users %s`,
secretExpr, enabledExpr, issuedExpr, confirmedExpr, createdExpr, updatedExpr, levelExpr, roleExpr, groupsExpr, permissionsExpr, whereClause)
activeExpr := "true"
if caps.hasActive {
activeExpr = "coalesce(active, true)"
}
proxyUUIDExpr := "NULL::uuid"
if caps.hasProxyUUID {
proxyUUIDExpr = "proxy_uuid"
}
proxyExpiresAtExpr := "NULL::timestamptz"
if caps.hasProxyUUIDExpiresAt {
proxyExpiresAtExpr = "proxy_uuid_expires_at"
}
return fmt.Sprintf(`SELECT uuid, username, email, email_verified, password, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s FROM users %s`,
secretExpr, enabledExpr, issuedExpr, confirmedExpr, createdExpr, updatedExpr, levelExpr, roleExpr, groupsExpr, permissionsExpr, activeExpr, proxyUUIDExpr, proxyExpiresAtExpr, whereClause)
}
func encodeStringSlice(values []string) ([]byte, error) {
@ -1108,3 +1191,47 @@ func (s *postgresStore) ListUsers(ctx context.Context) ([]User, error) {
return users, nil
}
func (s *postgresStore) DeleteUser(ctx context.Context, id string) error {
const query = "DELETE FROM users WHERE uuid = $1"
_, err := s.db.ExecContext(ctx, query, id)
return err
}
func (s *postgresStore) AddToBlacklist(ctx context.Context, email string) error {
const query = "INSERT INTO email_blacklist (email) VALUES ($1) ON CONFLICT (email) DO NOTHING"
_, err := s.db.ExecContext(ctx, query, strings.ToLower(email))
return err
}
func (s *postgresStore) RemoveFromBlacklist(ctx context.Context, email string) error {
const query = "DELETE FROM email_blacklist WHERE email = $1"
_, err := s.db.ExecContext(ctx, query, strings.ToLower(email))
return err
}
func (s *postgresStore) IsBlacklisted(ctx context.Context, email string) (bool, error) {
const query = "SELECT EXISTS(SELECT 1 FROM email_blacklist WHERE email = $1)"
var exists bool
err := s.db.QueryRowContext(ctx, query, strings.ToLower(email)).Scan(&exists)
return exists, err
}
func (s *postgresStore) ListBlacklist(ctx context.Context) ([]string, error) {
const query = "SELECT email FROM email_blacklist ORDER BY created_at DESC"
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var emails []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, nil
}

View File

@ -28,6 +28,9 @@ type User struct {
MFAConfirmedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
Active bool
ProxyUUID string
ProxyUUIDExpiresAt *time.Time
}
// Subscription represents a recurring or usage-based billing relationship.
@ -70,6 +73,13 @@ type Store interface {
CancelSubscription(ctx context.Context, userID, externalID string, cancelledAt time.Time) (*Subscription, error)
CreateIdentity(ctx context.Context, identity *Identity) error
ListUsers(ctx context.Context) ([]User, error)
DeleteUser(ctx context.Context, id string) error
// Email Blacklist
AddToBlacklist(ctx context.Context, email string) error
RemoveFromBlacklist(ctx context.Context, email string) error
IsBlacklisted(ctx context.Context, email string) (bool, error)
ListBlacklist(ctx context.Context) ([]string, error)
}
// Domain level errors returned by the store implementation.
@ -162,6 +172,8 @@ func (s *memoryStore) CreateUser(ctx context.Context, user *User) error {
normalizeUserRoleFields(&stored)
stored.Groups = cloneStringSlice(stored.Groups)
stored.Permissions = cloneStringSlice(stored.Permissions)
stored.Active = true
stored.ProxyUUID = uuid.NewString()
s.byID[userCopy.ID] = &stored
if loweredEmail != "" {
s.byEmail[loweredEmail] = &stored
@ -271,6 +283,9 @@ func (s *memoryStore) UpdateUser(ctx context.Context, user *User) error {
updated.Role = user.Role
updated.Groups = cloneStringSlice(user.Groups)
updated.Permissions = cloneStringSlice(user.Permissions)
updated.Active = user.Active
updated.ProxyUUID = user.ProxyUUID
updated.ProxyUUIDExpiresAt = user.ProxyUUIDExpiresAt
normalizeUserRoleFields(&updated)
if user.CreatedAt.IsZero() {
updated.CreatedAt = existing.CreatedAt
@ -632,3 +647,33 @@ func (s *memoryStore) ListUsers(ctx context.Context) ([]User, error) {
return result, nil
}
func (s *memoryStore) DeleteUser(ctx context.Context, id string) error {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.byID[id]
if !ok {
return nil
}
delete(s.byID, id)
delete(s.byEmail, strings.ToLower(user.Email))
delete(s.byName, strings.ToLower(user.Name))
return nil
}
func (s *memoryStore) AddToBlacklist(ctx context.Context, email string) error {
return nil
}
func (s *memoryStore) RemoveFromBlacklist(ctx context.Context, email string) error {
return nil
}
func (s *memoryStore) IsBlacklisted(ctx context.Context, email string) (bool, error) {
return false, nil
}
func (s *memoryStore) ListBlacklist(ctx context.Context) ([]string, error) {
return []string{}, nil
}

View File

@ -76,7 +76,16 @@ CREATE TABLE public.users (
mfa_secret_issued_at TIMESTAMPTZ,
mfa_confirmed_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ,
email_verified BOOLEAN GENERATED ALWAYS AS ((email_verified_at IS NOT NULL)) STORED
email_verified BOOLEAN GENERATED ALWAYS AS ((email_verified_at IS NOT NULL)) STORED,
active BOOLEAN NOT NULL DEFAULT TRUE,
proxy_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
proxy_uuid_expires_at TIMESTAMPTZ
);
CREATE TABLE public.email_blacklist (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE public.identities (