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

@ -76,16 +76,19 @@ func New(ctx context.Context, cfg Config) (Store, func(context.Context) error, e
}
type schemaCapabilities struct {
hasMFATOTPSecret bool
hasMFAEnabled bool
hasMFASecretIssuedAt bool
hasMFAConfirmedAt bool
hasCreatedAt bool
hasUpdatedAt bool
hasLevel bool
hasRole bool
hasGroups bool
hasPermissions bool
hasMFATOTPSecret bool
hasMFAEnabled bool
hasMFASecretIssuedAt bool
hasMFAConfirmedAt bool
hasCreatedAt bool
hasUpdatedAt bool
hasLevel bool
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

@ -13,21 +13,24 @@ import (
// User represents an account within the account service domain.
type User struct {
ID string
Name string
Email string
Level int
Role string
Groups []string
Permissions []string
EmailVerified bool
PasswordHash string
MFATOTPSecret string
MFAEnabled bool
MFASecretIssuedAt time.Time
MFAConfirmedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
ID string
Name string
Email string
Level int
Role string
Groups []string
Permissions []string
EmailVerified bool
PasswordHash string
MFATOTPSecret string
MFAEnabled bool
MFASecretIssuedAt time.Time
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 (