feat: add user management system with roles and permissions
Some checks failed
/ build ushare (push) Failing after 1m40s
/ clean (push) Successful in 2s

- Introduce SQLite persistence via GORM (stored at <data>/.ushare.db)
- Add Role model with two built-in roles: admin (all perms) and user (upload only)
- Add three permissions: user_manage, upload, token_manage (reserved)
- Rewrite UserManager: DB-backed login with in-memory session tokens
- Auto-seed default roles and admin user on first startup
- Add AuthPermission middleware for fine-grained permission checks
- Add /api/uauth/me endpoint for current session info
- Add /api/admin/* CRUD routes for user and role management
- Add admin console page (/admin) with user table and role permissions view
- Show admin console link in share page for users with user_manage permission

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
loveuer
2026-02-27 19:40:31 -08:00
parent 909a016a44
commit 5f187bb5d6
13 changed files with 1119 additions and 93 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/tool"
"github.com/loveuer/ushare/internal/handler"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
)
@@ -19,11 +20,16 @@ func Start(ctx context.Context) <-chan struct{} {
return c.SendStatus(http.StatusOK)
})
app.Get("/ushare/:code", handler.Fetch())
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
// Auth
app.Post("/api/uauth/login", handler.AuthLogin())
app.Get("/api/uauth/me", handler.AuthVerify(), handler.AuthMe())
// File sharing
app.Get("/ushare/:code", handler.Fetch())
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareNew())
app.Post("/api/ushare/:code", handler.ShareUpload())
// Local sharing (WebRTC signaling)
{
api := app.Group("/api/ulocal")
api.Post("/register", handler.LocalRegister())
@@ -34,7 +40,17 @@ func Start(ctx context.Context) <-chan struct{} {
api.Get("/ws", handler.LocalWS())
}
// 静态文件服务 - 作为中间件处理
// Admin
{
api := app.Group("/api/admin")
api.Get("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListUsers())
api.Post("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminCreateUser())
api.Put("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminUpdateUser())
api.Delete("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminDeleteUser())
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
}
// Frontend static files
app.Use(handler.ServeFrontendMiddleware())
ready := make(chan struct{})

View File

@@ -2,75 +2,149 @@ package controller
import (
"context"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/pkg/errors"
"strings"
"sync"
"time"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/pkg/errors"
)
type userManager struct {
sync.Mutex
ctx context.Context
um map[string]*model.User
ctx context.Context
sessions map[string]*model.Session
}
func (um *userManager) Login(username string, password string) (*model.User, error) {
var (
now = time.Now()
)
var UserManager = &userManager{
sessions: make(map[string]*model.Session),
}
if username != opt.Cfg.Username {
return nil, errors.New("账号或密码错误")
func (um *userManager) seed(ctx context.Context) error {
// Seed default roles if they don't exist
defaultRoles := []model.Role{
{
Name: model.RoleAdmin,
Label: "管理员",
Permissions: strings.Join([]string{model.PermUserManage, model.PermUpload, model.PermTokenManage}, ","),
},
{
Name: model.RoleUser,
Label: "用户",
Permissions: model.PermUpload,
},
}
if !tool.ComparePassword(password, opt.Cfg.Password) {
return nil, errors.New("账号或密码错误")
for i := range defaultRoles {
role := &defaultRoles[i]
var existing model.Role
if err := db.Default.Session(ctx).Where("name = ?", role.Name).First(&existing).Error; err != nil {
if err := db.Default.Session(ctx).Create(role).Error; err != nil {
return errors.Wrap(err, "seed role failed")
}
log.Debug("controller.userManager.seed: created role %s", role.Name)
}
}
op := &model.User{
Id: 1,
// Seed default admin user only if no users exist
var count int64
db.Default.Session(ctx).Model(&model.User{}).Count(&count)
if count > 0 {
return nil
}
var adminRole model.Role
if err := db.Default.Session(ctx).Where("name = ?", model.RoleAdmin).First(&adminRole).Error; err != nil {
return errors.Wrap(err, "get admin role failed")
}
username := opt.Cfg.Username
if username == "" {
username = "admin"
}
// opt.Cfg.Password is already hashed by opt.Init(); store it directly.
adminUser := &model.User{
Username: username,
LoginAt: now.Unix(),
Token: tool.RandomString(32),
Password: opt.Cfg.Password,
RoleID: adminRole.ID,
Active: true,
}
if err := db.Default.Session(ctx).Create(adminUser).Error; err != nil {
return errors.Wrap(err, "seed admin user failed")
}
log.Debug("controller.userManager.seed: created admin user %s", username)
return nil
}
func (um *userManager) Login(username, password string) (*model.Session, error) {
now := time.Now()
user := new(model.User)
if err := db.Default.Session().
Where("username = ? AND active = ?", username, true).
Preload("Role").
First(user).Error; err != nil {
return nil, errors.New("账号或密码错误")
}
if !tool.ComparePassword(password, user.Password) {
return nil, errors.New("账号或密码错误")
}
session := &model.Session{
UserID: user.ID,
Username: user.Username,
Role: user.Role.Name,
RoleLabel: user.Role.Label,
Permissions: user.Role.PermissionList(),
LoginAt: now.Unix(),
Token: tool.RandomString(32),
}
um.Lock()
defer um.Unlock()
um.um[op.Token] = op
um.sessions[session.Token] = session
return op, nil
return session, nil
}
func (um *userManager) Verify(token string) (*model.User, error) {
func (um *userManager) Verify(token string) (*model.Session, error) {
um.Lock()
defer um.Unlock()
op, ok := um.um[token]
session, ok := um.sessions[token]
if !ok {
return nil, errors.New("未登录或凭证已失效, 请重新登录")
}
return op, nil
return session, nil
}
func (um *userManager) Start(ctx context.Context) {
um.ctx = ctx
if err := um.seed(ctx); err != nil {
log.Fatal("controller.userManager.Start: seed failed: %s", err.Error())
}
go func() {
ticker := time.NewTicker(time.Minute)
for {
select {
case <-um.ctx.Done():
return
case now := <-ticker.C:
um.Lock()
for _, op := range um.um {
if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour {
delete(um.um, op.Token)
for token, session := range um.sessions {
if now.Unix()-session.LoginAt > 8*3600 {
delete(um.sessions, token)
}
}
um.Unlock()
@@ -78,9 +152,3 @@ func (um *userManager) Start(ctx context.Context) {
}
}()
}
var (
UserManager = &userManager{
um: make(map[string]*model.User),
}
)

211
internal/handler/admin.go Normal file
View File

@@ -0,0 +1,211 @@
package handler
import (
"net/http"
"strings"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/pkg/db"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/spf13/cast"
)
func AdminListUsers() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var users []model.User
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil {
log.Error("handler.AdminListUsers: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
}
}
func AdminCreateUser() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Username string `json:"username"`
Password string `json:"password"`
RoleID uint `json:"role_id"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名不能为空"})
}
if req.Password == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "密码不能为空"})
}
if req.RoleID == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "角色不能为空"})
}
if err := tool.CheckPassword(req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
var count int64
db.Default.Session().Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
}
user := &model.User{
Username: req.Username,
Password: tool.NewPassword(req.Password),
RoleID: req.RoleID,
Active: true,
}
if err := db.Default.Session().Create(user).Error; err != nil {
log.Error("handler.AdminCreateUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
}
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
}
}
func AdminUpdateUser() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
RoleID *uint `json:"role_id"`
Active *bool `json:"active"`
Password string `json:"password"`
}
id, err := cast.ToUintE(c.Param("id"))
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
}
session := c.Locals("user").(*model.Session)
user := new(model.User)
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
updates := map[string]any{}
if req.RoleID != nil && *req.RoleID != user.RoleID {
var newRole model.Role
if err := db.Default.Session().First(&newRole, *req.RoleID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
}
// If demoting from admin, ensure at least one other active admin remains
if user.Role.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
Count(&adminCount)
if adminCount == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法更改角色: 系统中至少需要一个管理员"})
}
}
updates["role_id"] = *req.RoleID
}
if req.Active != nil && *req.Active != user.Active {
if user.ID == session.UserID && !*req.Active {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
}
if user.Role.Name == model.RoleAdmin && !*req.Active {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
Count(&adminCount)
if adminCount == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法禁用: 系统中至少需要一个启用的管理员"})
}
}
updates["active"] = *req.Active
}
if req.Password != "" {
if err := tool.CheckPassword(req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
updates["password"] = tool.NewPassword(req.Password)
}
if len(updates) == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
}
if err := db.Default.Session().Model(user).Updates(updates).Error; err != nil {
log.Error("handler.AdminUpdateUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
}
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
}
}
func AdminDeleteUser() nf.HandlerFunc {
return func(c *nf.Ctx) error {
id, err := cast.ToUintE(c.Param("id"))
if err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
}
session := c.Locals("user").(*model.Session)
if session.UserID == id {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
}
user := new(model.User)
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
// Prevent deleting the last admin
if user.Role.Name == model.RoleAdmin {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND id != ?", user.RoleID, id).
Count(&adminCount)
if adminCount == 0 {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法删除最后一个管理员"})
}
}
if err := db.Default.Session().Delete(user).Error; err != nil {
log.Error("handler.AdminDeleteUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
}
}
func AdminListRoles() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var roles []model.Role
if err := db.Default.Session().Find(&roles).Error; err != nil {
log.Error("handler.AdminListRoles: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": roles})
}
}

View File

@@ -2,11 +2,11 @@ package handler
import (
"fmt"
"net/http"
"github.com/loveuer/nf"
"github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/model"
"github.com/loveuer/ushare/internal/opt"
"net/http"
)
func AuthVerify() nf.HandlerFunc {
@@ -14,33 +14,54 @@ func AuthVerify() nf.HandlerFunc {
if token = c.Get("Authorization"); token != "" {
return
}
token = c.Cookies("ushare")
return
}
return func(c *nf.Ctx) error {
if opt.Cfg.Username == "" || opt.Cfg.Password == "" {
return c.Next()
}
token := tokenFn(c)
if token == "" {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
op, err := controller.UserManager.Verify(token)
session, err := controller.UserManager.Verify(token)
if err != nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
}
c.Locals("user", op)
c.Locals("user", session)
return c.Next()
}
}
func AuthPermission(perm string) nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
for _, p := range session.Permissions {
if p == perm {
return c.Next()
}
}
return c.Status(http.StatusForbidden).JSON(map[string]string{"error": "forbidden", "msg": "权限不足"})
}
}
func AuthMe() nf.HandlerFunc {
return func(c *nf.Ctx) error {
session, ok := c.Locals("user").(*model.Session)
if !ok || session == nil {
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
}
}
func AuthLogin() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
@@ -49,22 +70,22 @@ func AuthLogin() nf.HandlerFunc {
}
var (
err error
req Req
op *model.User
err error
req Req
session *model.Session
)
if err = c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
}
if op, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
if session, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
}
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", op.Token, 8*3600)
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", session.Token, 8*3600)
c.SetHeader("Set-Cookie", header)
return c.Status(http.StatusOK).JSON(map[string]any{"data": op})
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
}
}

44
internal/model/role.go Normal file
View File

@@ -0,0 +1,44 @@
package model
import (
"strings"
"time"
)
const (
PermUserManage = "user_manage"
PermUpload = "upload"
PermTokenManage = "token_manage"
RoleAdmin = "admin"
RoleUser = "user"
)
type Role struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"uniqueIndex;not null" json:"name"`
Label string `gorm:"not null" json:"label"`
Permissions string `gorm:"not null" json:"permissions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (r *Role) HasPermission(perm string) bool {
for _, p := range r.PermissionList() {
if p == perm {
return true
}
}
return false
}
func (r *Role) PermissionList() []string {
list := make([]string, 0)
for _, p := range strings.Split(r.Permissions, ",") {
p = strings.TrimSpace(p)
if p != "" {
list = append(list, p)
}
}
return list
}

View File

@@ -1,10 +1,27 @@
package model
import "time"
// User is the GORM database model for persistent user storage.
type User struct {
Id int `json:"id"`
Username string `json:"username"`
Key string `json:"key"`
Password string `json:"-"`
LoginAt int64 `json:"login_at"`
Token string `json:"token"`
ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"`
RoleID uint `gorm:"not null" json:"role_id"`
Role Role `gorm:"foreignKey:RoleID" json:"role"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Session is the in-memory representation of an authenticated user.
// It is created on login and stored in the UserManager session map.
type Session struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
RoleLabel string `json:"role_label"`
Permissions []string `json:"permissions"`
LoginAt int64 `json:"login_at"`
Token string `json:"token"`
}

View File

@@ -46,6 +46,10 @@ func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
return session
}
func (c *Client) Migrate(models ...interface{}) error {
return c.cli.AutoMigrate(models...)
}
func (c *Client) Close() {
d, _ := c.cli.DB()
d.Close()