feat: implement user management features (pause, delete, blacklist, renew uuid)
This commit is contained in:
parent
0ea695c486
commit
693889f366
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 := group.Group("/admin")
|
||||||
admin.GET("/users/metrics", h.adminUsersMetrics)
|
admin.GET("/users/metrics", h.adminUsersMetrics)
|
||||||
admin.GET("/agents/status", h.adminAgentStatus)
|
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("")
|
authProtected := auth.Group("")
|
||||||
if h.tokenService != nil {
|
if h.tokenService != nil {
|
||||||
authProtected.Use(h.tokenService.AuthMiddleware())
|
authProtected.Use(h.tokenService.AuthMiddleware())
|
||||||
|
authProtected.Use(auth.RequireActiveUser(h.store))
|
||||||
}
|
}
|
||||||
|
|
||||||
authProtected.GET("/session", h.session)
|
authProtected.GET("/session", h.session)
|
||||||
@ -281,6 +282,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
|
|||||||
agentUser := r.Group("/api/agent")
|
agentUser := r.Group("/api/agent")
|
||||||
if h.tokenService != nil {
|
if h.tokenService != nil {
|
||||||
agentUser.Use(h.tokenService.AuthMiddleware())
|
agentUser.Use(h.tokenService.AuthMiddleware())
|
||||||
|
agentUser.Use(auth.RequireActiveUser(h.store))
|
||||||
}
|
}
|
||||||
agentUser.GET("/nodes", h.listAgentNodes)
|
agentUser.GET("/nodes", h.listAgentNodes)
|
||||||
|
|
||||||
@ -377,6 +379,16 @@ func (h *handler) register(c *gin.Context) {
|
|||||||
return
|
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, "@") {
|
if !strings.Contains(email, "@") {
|
||||||
respondError(c, http.StatusBadRequest, "invalid_email", "email must be a valid address")
|
respondError(c, http.StatusBadRequest, "invalid_email", "email must be a valid address")
|
||||||
return
|
return
|
||||||
@ -595,6 +607,16 @@ func (h *handler) sendEmailVerification(c *gin.Context) {
|
|||||||
return
|
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)
|
user, err := h.store.GetUserByEmail(ctx, email)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if strings.TrimSpace(user.Email) == "" {
|
if strings.TrimSpace(user.Email) == "" {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// Get current user ID to use as VLESS UUID
|
||||||
userID := auth.GetUserID(c)
|
userID := auth.GetUserID(c)
|
||||||
users := []string{}
|
if userID == "" {
|
||||||
if userID != "" {
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
users = append(users, userID)
|
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)
|
nodes := make([]vlessNode, 0)
|
||||||
|
|
||||||
if h.publicURL != "" {
|
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/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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
|||||||
@ -7,8 +7,39 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// Context keys for storing user information
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
|
|||||||
@ -76,16 +76,19 @@ func New(ctx context.Context, cfg Config) (Store, func(context.Context) error, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
type schemaCapabilities struct {
|
type schemaCapabilities struct {
|
||||||
hasMFATOTPSecret bool
|
hasMFATOTPSecret bool
|
||||||
hasMFAEnabled bool
|
hasMFAEnabled bool
|
||||||
hasMFASecretIssuedAt bool
|
hasMFASecretIssuedAt bool
|
||||||
hasMFAConfirmedAt bool
|
hasMFAConfirmedAt bool
|
||||||
hasCreatedAt bool
|
hasCreatedAt bool
|
||||||
hasUpdatedAt bool
|
hasUpdatedAt bool
|
||||||
hasLevel bool
|
hasLevel bool
|
||||||
hasRole bool
|
hasRole bool
|
||||||
hasGroups bool
|
hasGroups bool
|
||||||
hasPermissions bool
|
hasPermissions bool
|
||||||
|
hasActive bool
|
||||||
|
hasProxyUUID bool
|
||||||
|
hasProxyUUIDExpiresAt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c schemaCapabilities) supportsMFA() bool {
|
func (c schemaCapabilities) supportsMFA() bool {
|
||||||
@ -181,6 +184,25 @@ func (s *postgresStore) CreateUser(ctx context.Context, user *User) error {
|
|||||||
idx++
|
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)
|
query := fmt.Sprintf(`INSERT INTO users (%s)
|
||||||
VALUES (%s)
|
VALUES (%s)
|
||||||
RETURNING uuid, coalesce(created_at, now()), coalesce(updated_at, now()), email_verified`, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
|
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
|
roleValue sql.NullString
|
||||||
groupsRaw []byte
|
groupsRaw []byte
|
||||||
permissionsRaw []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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
@ -347,6 +372,12 @@ func scanUser(row rowScanner) (*User, error) {
|
|||||||
user.Role = strings.TrimSpace(roleValue.String)
|
user.Role = strings.TrimSpace(roleValue.String)
|
||||||
user.Groups = decodeStringSlice(groupsRaw)
|
user.Groups = decodeStringSlice(groupsRaw)
|
||||||
user.Permissions = decodeStringSlice(permissionsRaw)
|
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)
|
normalizeUserRoleFields(user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
@ -455,6 +486,22 @@ func (s *postgresStore) UpdateUser(ctx context.Context, user *User) error {
|
|||||||
idx++
|
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))
|
builder.WriteString(fmt.Sprintf(" WHERE uuid = $%d RETURNING ", idx))
|
||||||
args = append(args, user.ID)
|
args = append(args, user.ID)
|
||||||
idx++
|
idx++
|
||||||
@ -932,7 +979,25 @@ func (s *postgresStore) capabilities(ctx context.Context) (schemaCapabilities, e
|
|||||||
WHERE table_name = 'users'
|
WHERE table_name = 'users'
|
||||||
AND table_schema = ANY (current_schemas(false))
|
AND table_schema = ANY (current_schemas(false))
|
||||||
AND column_name = 'permissions'
|
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)
|
row := s.db.QueryRowContext(ctx, query)
|
||||||
var caps schemaCapabilities
|
var caps schemaCapabilities
|
||||||
@ -947,6 +1012,9 @@ func (s *postgresStore) capabilities(ctx context.Context) (schemaCapabilities, e
|
|||||||
&caps.hasRole,
|
&caps.hasRole,
|
||||||
&caps.hasGroups,
|
&caps.hasGroups,
|
||||||
&caps.hasPermissions,
|
&caps.hasPermissions,
|
||||||
|
&caps.hasActive,
|
||||||
|
&caps.hasProxyUUID,
|
||||||
|
&caps.hasProxyUUIDExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return schemaCapabilities{}, err
|
return schemaCapabilities{}, err
|
||||||
}
|
}
|
||||||
@ -1007,8 +1075,23 @@ func (s *postgresStore) selectUserQuery(caps schemaCapabilities, whereClause str
|
|||||||
permissionsExpr = "coalesce(permissions, '[]'::jsonb)"
|
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`,
|
activeExpr := "true"
|
||||||
secretExpr, enabledExpr, issuedExpr, confirmedExpr, createdExpr, updatedExpr, levelExpr, roleExpr, groupsExpr, permissionsExpr, whereClause)
|
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) {
|
func encodeStringSlice(values []string) ([]byte, error) {
|
||||||
@ -1108,3 +1191,47 @@ func (s *postgresStore) ListUsers(ctx context.Context) ([]User, error) {
|
|||||||
|
|
||||||
return users, nil
|
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.
|
// User represents an account within the account service domain.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Email string
|
Email string
|
||||||
Level int
|
Level int
|
||||||
Role string
|
Role string
|
||||||
Groups []string
|
Groups []string
|
||||||
Permissions []string
|
Permissions []string
|
||||||
EmailVerified bool
|
EmailVerified bool
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
MFATOTPSecret string
|
MFATOTPSecret string
|
||||||
MFAEnabled bool
|
MFAEnabled bool
|
||||||
MFASecretIssuedAt time.Time
|
MFASecretIssuedAt time.Time
|
||||||
MFAConfirmedAt time.Time
|
MFAConfirmedAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
Active bool
|
||||||
|
ProxyUUID string
|
||||||
|
ProxyUUIDExpiresAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription represents a recurring or usage-based billing relationship.
|
// 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)
|
CancelSubscription(ctx context.Context, userID, externalID string, cancelledAt time.Time) (*Subscription, error)
|
||||||
CreateIdentity(ctx context.Context, identity *Identity) error
|
CreateIdentity(ctx context.Context, identity *Identity) error
|
||||||
ListUsers(ctx context.Context) ([]User, 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.
|
// Domain level errors returned by the store implementation.
|
||||||
@ -162,6 +172,8 @@ func (s *memoryStore) CreateUser(ctx context.Context, user *User) error {
|
|||||||
normalizeUserRoleFields(&stored)
|
normalizeUserRoleFields(&stored)
|
||||||
stored.Groups = cloneStringSlice(stored.Groups)
|
stored.Groups = cloneStringSlice(stored.Groups)
|
||||||
stored.Permissions = cloneStringSlice(stored.Permissions)
|
stored.Permissions = cloneStringSlice(stored.Permissions)
|
||||||
|
stored.Active = true
|
||||||
|
stored.ProxyUUID = uuid.NewString()
|
||||||
s.byID[userCopy.ID] = &stored
|
s.byID[userCopy.ID] = &stored
|
||||||
if loweredEmail != "" {
|
if loweredEmail != "" {
|
||||||
s.byEmail[loweredEmail] = &stored
|
s.byEmail[loweredEmail] = &stored
|
||||||
@ -271,6 +283,9 @@ func (s *memoryStore) UpdateUser(ctx context.Context, user *User) error {
|
|||||||
updated.Role = user.Role
|
updated.Role = user.Role
|
||||||
updated.Groups = cloneStringSlice(user.Groups)
|
updated.Groups = cloneStringSlice(user.Groups)
|
||||||
updated.Permissions = cloneStringSlice(user.Permissions)
|
updated.Permissions = cloneStringSlice(user.Permissions)
|
||||||
|
updated.Active = user.Active
|
||||||
|
updated.ProxyUUID = user.ProxyUUID
|
||||||
|
updated.ProxyUUIDExpiresAt = user.ProxyUUIDExpiresAt
|
||||||
normalizeUserRoleFields(&updated)
|
normalizeUserRoleFields(&updated)
|
||||||
if user.CreatedAt.IsZero() {
|
if user.CreatedAt.IsZero() {
|
||||||
updated.CreatedAt = existing.CreatedAt
|
updated.CreatedAt = existing.CreatedAt
|
||||||
@ -632,3 +647,33 @@ func (s *memoryStore) ListUsers(ctx context.Context) ([]User, error) {
|
|||||||
|
|
||||||
return result, nil
|
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_secret_issued_at TIMESTAMPTZ,
|
||||||
mfa_confirmed_at TIMESTAMPTZ,
|
mfa_confirmed_at TIMESTAMPTZ,
|
||||||
email_verified_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 (
|
CREATE TABLE public.identities (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user