feat: implement user management features (pause, delete, blacklist, renew uuid)
This commit is contained in:
parent
bb4abafda6
commit
87cc3a248e
165
api/admin_users.go
Normal file
165
api/admin_users.go
Normal 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"})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
22
api/api.go
22
api/api.go
@ -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) == "" {
|
||||
|
||||
@ -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
2
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user