From d64c839b104d80540eb8721ae5470327f8682b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Mon, 22 Jun 2026 18:49:33 +0800 Subject: [PATCH] feat: add role-based access control and user article management - Add AdminRequired middleware to all /admin routes for proper authorization - Restrict admin panel, comments, users, settings to admin role only - Create separate /my/articles routes for regular users to manage their own articles - Add MyArticlesPage handler for users to view/edit/delete their own content - Update navigation menus with role-based visibility - Admin dropdown shows only 'Admin Panel' link, other features in dashboard - Regular users see 'My Articles' link in profile dropdown - Add i18n translations for 'my_articles' and 'comment_manage' keys - Fix permission boundary issues where author role could access admin settings Security improvements: - Platform settings now require admin role - Comment moderation requires admin role - User management requires admin role - Regular users can only manage their own articles Co-Authored-By: Claude Fable 5 --- handlers/my_articles.go | 169 ++++++++++++++++++++++++++++ i18n/i18n.go | 28 +++++ main.go | 45 ++++++-- templates/admin/dashboard.html | 6 +- templates/layouts/base.html | 13 +-- templates/user/my_article_form.html | 96 ++++++++++++++++ templates/user/my_articles.html | 60 ++++++++++ test_permissions.md | 88 +++++++++++++++ 8 files changed, 486 insertions(+), 19 deletions(-) create mode 100644 handlers/my_articles.go create mode 100644 templates/user/my_article_form.html create mode 100644 templates/user/my_articles.html create mode 100644 test_permissions.md diff --git a/handlers/my_articles.go b/handlers/my_articles.go new file mode 100644 index 0000000..d17e543 --- /dev/null +++ b/handlers/my_articles.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "go_blog/models" +) + +// MyArticlesPage renders the logged-in user's article management page. +func MyArticlesPage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + session := sessions.Default(c) + userID := session.Get("user_id") + + var articles []models.Article + db.Where("author_id = ?", userID).Order("created_at DESC").Find(&articles) + + data := DefaultData(c) + data["Title"] = tr["my_articles_title"] + data["Articles"] = articles + c.HTML(http.StatusOK, "my_articles", data) + } +} + +// MyArticleCreatePage renders the article creation form for regular users. +func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + token := randomToken() + session := sessions.Default(c) + session.Set("pending_token", token) + session.Save() + + renderMyArticleForm(c, db, articleForm{ + Action: "/my/articles/new", + TitleText: tr["article_create_title"], + SessionToken: token, + }, "") + } +} + +// MyArticleCreate handles the POST request to create a new article for regular users. +func MyArticleCreate(db *gorm.DB) gin.HandlerFunc { + return ArticleCreate(db) // Reuse the same logic +} + +// MyArticleEditPage renders the article edit form for the logged-in user's own articles. +func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + id := c.Param("id") + session := sessions.Default(c) + userID := session.Get("user_id") + + var article models.Article + if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil { + c.Redirect(http.StatusFound, "/my/articles") + return + } + + renderMyArticleForm(c, db, articleForm{ + Title: article.Title, + Slug: article.Slug, + Summary: article.Summary, + Content: article.Content, + Cover: article.Cover, + StatusStr: strconv.Itoa(article.Status), + IsTop: article.IsTop, + Action: "/my/articles/" + id + "/edit", + TitleText: tr["article_edit_title"], + ArticleID: article.ID, + }, "") + } +} + +// MyArticleUpdate handles the POST request to update the user's own article. +func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + id := c.Param("id") + session := sessions.Default(c) + userID := session.Get("user_id") + + var article models.Article + if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil { + c.Redirect(http.StatusFound, "/my/articles") + return + } + + f := parseArticleForm(c) + f.Action = "/my/articles/" + id + "/edit" + f.TitleText = tr["article_edit_title"] + f.ArticleID = article.ID + + if f.Title == "" { + renderMyArticleForm(c, db, f, tr["article_title_required"]) + return + } + if f.Content == "" { + renderMyArticleForm(c, db, f, tr["article_content_required"]) + return + } + + if f.Slug == "" { + f.Slug = generateSlug(f.Title) + if f.Slug == "" { + f.Slug = fallbackSlug(article.ID) + } + } + + newStatus := statusFromForm(f.StatusStr) + + // Stamp the publish time the first time an article is published. + wasPublished := article.Status == models.ArticlePublished + var publishedAt *time.Time = article.PublishedAt + if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { + now := time.Now() + publishedAt = &now + } + + updates := map[string]interface{}{ + "title": f.Title, + "slug": f.Slug, + "summary": f.Summary, + "content": f.Content, + "cover": f.Cover, + "status": newStatus, + "is_top": f.IsTop, + "published_at": publishedAt, + } + + if err := db.Model(&article).Updates(updates).Error; err != nil { + renderMyArticleForm(c, db, f, tr["article_error"]) + return + } + + c.Redirect(http.StatusFound, "/my/articles") + } +} + +// MyArticleDelete soft-deletes the user's own article. +func MyArticleDelete(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + session := sessions.Default(c) + userID := session.Get("user_id") + + db.Where("id = ? AND author_id = ?", id, userID).Delete(&models.Article{}) + c.Redirect(http.StatusFound, "/my/articles") + } +} + +// renderMyArticleForm renders the article form for regular users. +func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) { + data := DefaultData(c) + data["Title"] = f.TitleText + if errMsg != "" { + data["Error"] = errMsg + } + applyFormToData(data, f) + c.HTML(http.StatusOK, "my_article_form", data) +} diff --git a/i18n/i18n.go b/i18n/i18n.go index e9d810e..d93ff5a 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -72,6 +72,7 @@ var translations = map[Lang]map[string]string{ // Profile dropdown & page "profile": "Profile", "admin_panel": "Admin Panel", + "my_articles": "My Articles", "profile_title": "Edit Profile", "profile_avatar": "Avatar", "profile_change_avatar": "Change Avatar", @@ -102,17 +103,28 @@ var translations = map[Lang]map[string]string{ // Article creation "article_create_title": "Create Article", + "article_field_title": "Title", "article_title": "Title", + "article_field_slug": "Slug", "article_slug": "Slug", "article_slug_hint": "Auto-generated from title if empty", + "article_slug_help": "Auto-generated from title if empty", + "article_field_summary": "Summary", "article_summary": "Summary", + "article_field_content": "Content", "article_content": "Content", + "article_field_cover": "Cover Image URL", "article_cover": "Cover Image URL", + "article_cover_help": "Optional cover image URL", + "article_field_status": "Status", "article_status": "Status", "article_draft": "Draft", "article_published": "Published", + "article_field_is_top": "Pin to top", "article_is_top": "Pin to top", "article_save_draft": "Save Draft", + "article_save": "Save", + "article_cancel": "Cancel", "article_publish": "Publish", "article_created": "Article created successfully.", "article_error": "Failed to create article.", @@ -135,6 +147,7 @@ var translations = map[Lang]map[string]string{ // Article management "article_list_title": "Articles", + "my_articles_title": "My Articles", "article_edit_title": "Edit Article", "article_manage": "Manage Articles", "article_edit": "Edit", @@ -241,6 +254,7 @@ var translations = map[Lang]map[string]string{ // Admin: comments "admin_comments": "Comments", + "comment_manage": "Manage Comments", "admin_comments_title": "Comments", "comment_status_pending": "Pending", "comment_status_approved": "Approved", @@ -368,6 +382,7 @@ var translations = map[Lang]map[string]string{ // 个人中心下拉菜单 & 页面 "profile": "个人信息", "admin_panel": "后台管理", + "my_articles": "我的文章", "profile_title": "编辑个人信息", "profile_avatar": "头像", "profile_change_avatar": "更换头像", @@ -398,17 +413,28 @@ var translations = map[Lang]map[string]string{ // 文章创建 "article_create_title": "新建文章", + "article_field_title": "标题", "article_title": "标题", + "article_field_slug": "Slug", "article_slug": "Slug", "article_slug_hint": "留空则从标题自动生成", + "article_slug_help": "留空则从标题自动生成", + "article_field_summary": "摘要", "article_summary": "摘要", + "article_field_content": "正文", "article_content": "正文", + "article_field_cover": "封面图 URL", "article_cover": "封面图 URL", + "article_cover_help": "可选的封面图片链接", + "article_field_status": "状态", "article_status": "状态", "article_draft": "草稿", "article_published": "已发布", + "article_field_is_top": "置顶", "article_is_top": "置顶", "article_save_draft": "保存草稿", + "article_save": "保存", + "article_cancel": "取消", "article_publish": "发布", "article_created": "文章创建成功。", "article_error": "创建文章失败。", @@ -431,6 +457,7 @@ var translations = map[Lang]map[string]string{ // 文章管理 "article_list_title": "文章管理", + "my_articles_title": "我的文章", "article_edit_title": "编辑文章", "article_manage": "文章管理", "article_edit": "编辑", @@ -537,6 +564,7 @@ var translations = map[Lang]map[string]string{ // 后台:评论 "admin_comments": "评论管理", + "comment_manage": "评论管理", "admin_comments_title": "评论管理", "comment_status_pending": "待审核", "comment_status_approved": "已通过", diff --git a/main.go b/main.go index 73565b9..45d7130 100644 --- a/main.go +++ b/main.go @@ -56,9 +56,9 @@ func main() { router.GET("/article/:slug", handlers.ArticleDetail(db)) router.POST("/article/:slug/comments", handlers.PostComment(db)) - // Protected admin routes. + // Protected admin routes (admin role only). admin := router.Group("/admin") - admin.Use(middleware.AuthRequired()) + admin.Use(middleware.AuthRequired(), middleware.AdminRequired(db)) { admin.GET("", handlers.AdminDashboard(db)) admin.GET("/articles", handlers.ArticleListPage(db)) @@ -68,10 +68,16 @@ func main() { admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db)) admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) - admin.GET("/comments", handlers.CommentListPage(db)) - admin.POST("/comments/:id/approve", handlers.CommentApprove(db)) - admin.POST("/comments/:id/reject", handlers.CommentReject(db)) - admin.POST("/comments/:id/delete", handlers.CommentDelete(db)) + } + + // Protected admin comment management routes (admin role only). + comments := router.Group("/admin/comments") + comments.Use(middleware.AuthRequired(), middleware.AdminRequired(db)) + { + comments.GET("", handlers.CommentListPage(db)) + comments.POST("/:id/approve", handlers.CommentApprove(db)) + comments.POST("/:id/reject", handlers.CommentReject(db)) + comments.POST("/:id/delete", handlers.CommentDelete(db)) } // Protected admin user-management routes (admin role only). @@ -86,9 +92,9 @@ func main() { users.POST("/:id/delete", handlers.UserDelete(db)) } - // Protected article attachment routes (AJAX uploads / management). + // Protected article attachment routes (admin role only). attachments := router.Group("/admin/articles") - attachments.Use(middleware.AuthRequired()) + attachments.Use(middleware.AuthRequired(), middleware.AdminRequired(db)) { attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path)) @@ -97,7 +103,7 @@ func main() { // Protected admin settings routes (platform configuration). settings := router.Group("/admin/settings") - settings.Use(middleware.AuthRequired()) + settings.Use(middleware.AuthRequired(), middleware.AdminRequired(db)) { settings.GET("/site", handlers.SiteSettingsPage(db)) settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) @@ -118,6 +124,27 @@ func main() { profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path)) } + // Protected user article management routes (for non-admin users). + myArticles := router.Group("/my") + myArticles.Use(middleware.AuthRequired()) + { + myArticles.GET("/articles", handlers.MyArticlesPage(db)) + myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db)) + myArticles.POST("/articles/new", handlers.MyArticleCreate(db)) + myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db)) + myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db)) + myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db)) + } + + // Protected article attachment routes for user articles. + myAttachments := router.Group("/my/articles") + myAttachments.Use(middleware.AuthRequired()) + { + myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) + myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path)) + myAttachments.GET("/:id/attachments", handlers.ListAttachments(db)) + } + // 9. Start the server. addr := fmt.Sprintf(":%s", cfg.Port) log.Printf("Go Blog starting on http://localhost%s", addr) diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 8e7620b..751cdb8 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -55,11 +55,15 @@ class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"> {{index .Tr "dash_user_mgmt"}} - {{end}} + + {{index .Tr "comment_manage"}} + {{index .Tr "settings_nav"}} + {{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 0f9fe44..adaa894 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -43,19 +43,14 @@ {{index .Tr "profile"}} + + {{index .Tr "my_articles"}} + {{if eq .Role "admin"}} +
{{index .Tr "admin_panel"}} - - {{index .Tr "article_manage"}} - - - {{index .Tr "admin_comments"}} - - - {{index .Tr "admin_users"}} - {{end}}
diff --git a/templates/user/my_article_form.html b/templates/user/my_article_form.html new file mode 100644 index 0000000..7a7f3aa --- /dev/null +++ b/templates/user/my_article_form.html @@ -0,0 +1,96 @@ +{{define "my_article_form"}} +{{template "header" .}} +
+
+

{{.FormTitleText}}

+
+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + + + {{if .SessionToken}} + + {{end}} + +
+ + +
+ +
+ + +

{{index .Tr "article_slug_help"}}

+
+ +
+ + +
+ +
+ + +
+ +
+ + +

{{index .Tr "article_cover_help"}}

+
+ +
+
+ + +
+ +
+ +
+
+ +
+ + + {{index .Tr "article_cancel"}} + +
+ +
+ + + +{{template "footer" .}} +{{end}} diff --git a/templates/user/my_articles.html b/templates/user/my_articles.html new file mode 100644 index 0000000..22de8bc --- /dev/null +++ b/templates/user/my_articles.html @@ -0,0 +1,60 @@ +{{define "my_articles"}} +{{template "header" .}} +
+
+

{{index .Tr "my_articles_title"}}

+ + + + + {{index .Tr "dash_new_article"}} + +
+ +
+ + + + + + + + + + + {{range .Articles}} + + + + + + + {{else}} + + + + {{end}} + +
{{index .Tr "article_col_title"}}{{index .Tr "article_col_status"}}{{index .Tr "article_col_published"}}{{index .Tr "article_col_actions"}}
+ {{.Title}} + {{if .IsTop}}{{index $.Tr "article_is_top"}}{{end}} + + {{if eq .Status 1}}{{index $.Tr "article_published"}} + {{else if eq .Status 2}}{{index $.Tr "article_archived"}} + {{else}}{{index $.Tr "article_draft"}}{{end}} + + {{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02 15:04"}}{{else}}—{{end}} + + {{index $.Tr "article_edit"}} +
+ +
+
{{index .Tr "home_no_posts"}}
+
+
+{{template "footer" .}} +{{end}} diff --git a/test_permissions.md b/test_permissions.md new file mode 100644 index 0000000..44667b2 --- /dev/null +++ b/test_permissions.md @@ -0,0 +1,88 @@ +# 权限边界修复验证 + +## 修复内容 + +### 问题 +普通用户(author角色)能够访问和修改平台设置,存在严重的权限漏洞。 + +### 修复的路由组 + +1. **平台设置路由** (`/admin/settings/*`) + - 修复前: `middleware.AuthRequired()` - 任何登录用户都可访问 + - 修复后: `middleware.AuthRequired(), middleware.AdminRequired(db)` - 仅管理员可访问 + - 影响路由: + - GET/POST `/admin/settings/site` - 站点设置 + - GET/POST `/admin/settings/upload` - 上传设置 + - GET/POST `/admin/settings/download` - 下载设置 + - GET/POST `/admin/settings/comments` - 评论设置 + +2. **评论管理路由** (`/admin/comments/*`) + - 修复前: 在 admin 组下,仅 `AuthRequired()` + - 修复后: 独立路由组,使用 `AuthRequired(), AdminRequired(db)` + - 影响路由: + - GET `/admin/comments` - 评论列表 + - POST `/admin/comments/:id/approve` - 批准评论 + - POST `/admin/comments/:id/reject` - 拒绝评论 + - POST `/admin/comments/:id/delete` - 删除评论 + +## 权限矩阵 + +| 路由组 | 功能 | 登录要求 | 角色要求 | 说明 | +|--------|------|----------|----------|------| +| `/admin` | 仪表板 | ✓ | - | 所有登录用户可访问 | +| `/admin/articles` | 文章管理 | ✓ | - | 允许作者管理自己的文章 | +| `/admin/articles/attachments` | 附件管理 | ✓ | - | 作者上传文章附件 | +| `/admin/comments` | 评论管理 | ✓ | admin | ✅ 仅管理员 | +| `/admin/users` | 用户管理 | ✓ | admin | ✅ 仅管理员 | +| `/admin/settings` | 平台设置 | ✓ | admin | ✅ 仅管理员 | +| `/profile` | 个人资料 | ✓ | - | 所有登录用户 | + +## 验证步骤 + +### 1. 使用管理员账户测试 +```bash +# 登录管理员账户 +curl -X POST http://localhost:3000/login \ + -d "username=admin&password=admin123" + +# 应该能访问设置页面 +curl http://localhost:3000/admin/settings/site + +# 应该能访问评论管理 +curl http://localhost:3000/admin/comments +``` + +### 2. 使用普通用户账户测试 +```bash +# 登录普通用户 +curl -X POST http://localhost:3000/login \ + -d "username=author&password=password" + +# 应该被重定向到 /admin(403效果) +curl -L http://localhost:3000/admin/settings/site + +# 应该被重定向到 /admin(403效果) +curl -L http://localhost:3000/admin/comments +``` + +### 3. 预期行为 +- **管理员**: 可以访问所有 `/admin/*` 路由 +- **普通用户(author)**: + - ✓ 可以访问 `/admin` 仪表板 + - ✓ 可以管理文章 (`/admin/articles/*`) + - ✗ **不能**访问平台设置 (`/admin/settings/*`) + - ✗ **不能**访问评论管理 (`/admin/comments/*`) + - ✗ **不能**访问用户管理 (`/admin/users/*`) + +## 安全建议 + +### 已修复 +- ✅ 平台设置访问控制 +- ✅ 评论管理访问控制 +- ✅ 用户管理访问控制 + +### 未来改进建议 +1. **细粒度文章权限**: 作者只能编辑/删除自己的文章 +2. **审计日志**: 记录敏感操作(设置修改、用户管理) +3. **会话超时**: 考虑缩短敏感操作的会话时间 +4. **CSRF保护**: 为所有POST请求添加CSRF token