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"`
|
Password string `form:"password" json:"password" validate:"required,min=6"`
|
||||||
FirstName string `form:"first_name" json:"first_name,omitempty"`
|
FirstName string `form:"first_name" json:"first_name,omitempty"`
|
||||||
LastName string `form:"last_name" json:"last_name,omitempty"`
|
LastName string `form:"last_name" json:"last_name,omitempty"`
|
||||||
|
Role string `form:"role" json:"role,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
@@ -69,6 +70,7 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
|||||||
password := c.FormValue("password")
|
password := c.FormValue("password")
|
||||||
firstName := c.FormValue("first_name")
|
firstName := c.FormValue("first_name")
|
||||||
lastName := c.FormValue("last_name")
|
lastName := c.FormValue("last_name")
|
||||||
|
role := c.FormValue("role")
|
||||||
|
|
||||||
if email == "" || username == "" || password == "" {
|
if email == "" || username == "" || password == "" {
|
||||||
// Fallback to JSON binding
|
// Fallback to JSON binding
|
||||||
@@ -90,9 +92,10 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
|||||||
password = req.Password
|
password = req.Password
|
||||||
firstName = req.FirstName
|
firstName = req.FirstName
|
||||||
lastName = req.LastName
|
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 err := c.Validate(&req); err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusBadRequest, `<div class="text-red-500">`+err.Error()+`</div>`)
|
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"})
|
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
|
// Hash password
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
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"})
|
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
|
// Create user
|
||||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
@@ -131,6 +161,8 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
|||||||
PasswordHash: string(hashedPassword),
|
PasswordHash: string(hashedPassword),
|
||||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
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 err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
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"})
|
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)
|
// Delete user (this will cascade to delete all related data)
|
||||||
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||||
if err != nil {
|
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()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user