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 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:49:33 +08:00
co-authored by Claude Fable 5
parent cefaeac618
commit d64c839b10
8 changed files with 486 additions and 19 deletions
+169
View File
@@ -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)
}
+28
View File
@@ -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": "已通过",
+36 -9
View File
@@ -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)
+5 -1
View File
@@ -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"}}
</a>
{{end}}
<a href="/admin/comments"
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 "comment_manage"}}
</a>
<a href="/admin/settings/site"
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 "settings_nav"}}
</a>
{{end}}
</div>
</div>
</section>
+4 -9
View File
@@ -43,19 +43,14 @@
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "profile"}}
</a>
<a href="/my/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "my_articles"}}
</a>
{{if eq .Role "admin"}}
<div class="border-t border-gray-100 my-1"></div>
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "admin_panel"}}
</a>
<a href="/admin/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "article_manage"}}
</a>
<a href="/admin/comments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "admin_comments"}}
</a>
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "admin_users"}}
</a>
{{end}}
<div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0">
+96
View File
@@ -0,0 +1,96 @@
{{define "my_article_form"}}
{{template "header" .}}
<section class="max-w-4xl mx-auto px-4 py-12">
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
</div>
{{if .Error}}
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{{.Error}}
</div>
{{end}}
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
{{if .SessionToken}}
<input type="hidden" name="session_token" value="{{.SessionToken}}">
{{end}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_title"}}</label>
<input type="text" name="title" value="{{.FormTitle}}" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_slug"}}</label>
<input type="text" name="slug" value="{{.FormSlug}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_slug_help"}}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_summary"}}</label>
<textarea name="summary" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormSummary}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_content"}}</label>
<textarea id="content" name="content" rows="20"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormContent}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_cover"}}</label>
<input type="text" name="cover" value="{{.FormCover}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>
<select name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
</select>
</div>
<div class="flex items-center">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
</label>
</div>
</div>
<div class="flex gap-3">
<button type="submit"
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "article_save"}}
</button>
<a href="/my/articles"
class="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors">
{{index .Tr "article_cancel"}}
</a>
</div>
</form>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
var easyMDE = new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
status: false,
toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
});
});
</script>
{{template "footer" .}}
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "my_articles"}}
{{template "header" .}}
<section class="max-w-5xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "my_articles_title"}}</h2>
<a href="/my/articles/new"
class="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{{index .Tr "dash_new_article"}}
</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="w-full text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_title"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_status"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_published"}}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "article_col_actions"}}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{{range .Articles}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-800">
{{.Title}}
{{if .IsTop}}<span class="ml-1 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">{{index $.Tr "article_is_top"}}</span>{{end}}
</td>
<td class="px-4 py-3 text-sm">
{{if eq .Status 1}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">{{index $.Tr "article_published"}}</span>
{{else if eq .Status 2}}<span class="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded">{{index $.Tr "article_archived"}}</span>
{{else}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded">{{index $.Tr "article_draft"}}</span>{{end}}
</td>
<td class="px-4 py-3 text-sm text-gray-500">
{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<a href="/my/articles/{{.ID}}/edit"
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/my/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="4" class="px-4 py-12 text-center text-gray-500">{{index .Tr "home_no_posts"}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{template "footer" .}}
{{end}}
+88
View File
@@ -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