feat: Implement first-user admin and last-user protection
- First registered user automatically becomes admin regardless of request - Prevent deletion of the last user account to protect system - Enhanced role validation and HTMX error handling - Proper pgx 5 database standards throughout Security improvements: - Auto-admin for first user ensures system always has administrator - Last-user protection prevents system from having zero users - Role validation ensures only 'user' or 'admin' roles accepted
This commit is contained in:
@@ -35,6 +35,7 @@ type RegisterRequest struct {
|
||||
Password string `form:"password" json:"password" validate:"required,min=6"`
|
||||
FirstName string `form:"first_name" json:"first_name,omitempty"`
|
||||
LastName string `form:"last_name" json:"last_name,omitempty"`
|
||||
Role string `form:"role" json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -69,6 +70,7 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
password := c.FormValue("password")
|
||||
firstName := c.FormValue("first_name")
|
||||
lastName := c.FormValue("last_name")
|
||||
role := c.FormValue("role")
|
||||
|
||||
if email == "" || username == "" || password == "" {
|
||||
// Fallback to JSON binding
|
||||
@@ -90,9 +92,10 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
password = req.Password
|
||||
firstName = req.FirstName
|
||||
lastName = req.LastName
|
||||
role = req.Role
|
||||
}
|
||||
|
||||
req := RegisterRequest{Email: email, Username: username, Password: password, FirstName: firstName, LastName: lastName}
|
||||
req := RegisterRequest{Email: email, Username: username, Password: password, FirstName: firstName, LastName: lastName, Role: role}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
|
||||
@@ -115,6 +118,15 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
|
||||
}
|
||||
|
||||
// Check if this is the first user - if so, make them admin regardless of request
|
||||
users, err := h.db.ListUsers(c.Request().Context())
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to check existing users</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -124,6 +136,24 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
// Set role - first user is always admin, otherwise validate requested role
|
||||
var userRole string
|
||||
if len(users) == 0 {
|
||||
userRole = "admin" // First user is always admin
|
||||
} else {
|
||||
userRole = req.Role
|
||||
if userRole == "" {
|
||||
userRole = "user"
|
||||
}
|
||||
// Validate role for subsequent users
|
||||
if userRole != "user" && userRole != "admin" {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Invalid role. Must be 'user' or 'admin'</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||
Email: req.Email,
|
||||
@@ -131,6 +161,8 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
PasswordHash: string(hashedPassword),
|
||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
Theme: pgtype.Text{String: "tokyo-night", Valid: true}, // default theme
|
||||
Role: userRole,
|
||||
})
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
@@ -706,9 +738,29 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
// Check if this is the last user - prevent deletion
|
||||
users, err := h.db.ListUsers(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
|
||||
}
|
||||
|
||||
if len(users) == 1 {
|
||||
// Convert user UUIDs to string for comparison
|
||||
lastUserID := uuid.UUID(users[0].ID.Bytes).String()
|
||||
if lastUserID == userID {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">Cannot delete the last user account</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last user account"})
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user (this will cascade to delete all related data)
|
||||
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to delete account</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user