feat: add token-based API access (v0.6.0)
Some checks are pending
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Waiting to run
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Waiting to run
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Waiting to run
Some checks are pending
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Waiting to run
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Waiting to run
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Waiting to run
- Add Token GORM model with UserID/Name/Token/LastUsedAt/ExpiresAt fields
- Add TokenManager controller: List/Create/Delete/Verify operations
- Add token HTTP handlers: list, create, revoke
- Update AuthVerify to support Bearer token auth; API tokens use "ust_" prefix to distinguish from session tokens
- Add one-step file upload endpoint: PUT /api/v1/upload/:filename (returns {"status":200,"data":{"code":"..."}})
- Add token management routes: GET/POST/DELETE /api/token
- Add /self page: personal center with account info, token management table, and curl usage guide
- Add "个人中心 / API Token" nav link for users with token_manage permission
🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
@@ -50,6 +50,17 @@ func Start(ctx context.Context) <-chan struct{} {
|
||||
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
|
||||
}
|
||||
|
||||
// Token management
|
||||
{
|
||||
api := app.Group("/api/token")
|
||||
api.Get("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenList())
|
||||
api.Post("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenCreate())
|
||||
api.Delete("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenDelete())
|
||||
}
|
||||
|
||||
// API v1 - token-authenticated file upload
|
||||
app.Put("/api/v1/upload/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareAPIUpload())
|
||||
|
||||
// Frontend static files
|
||||
app.Use(handler.ServeFrontendMiddleware())
|
||||
|
||||
|
||||
97
internal/controller/token.go
Normal file
97
internal/controller/token.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/pkg/db"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type tokenManager struct{}
|
||||
|
||||
var TokenManager = &tokenManager{}
|
||||
|
||||
// List returns all tokens belonging to a user (token value is not exposed).
|
||||
func (tm *tokenManager) List(userID uint) ([]model.Token, error) {
|
||||
var tokens []model.Token
|
||||
if err := db.Default.Session().Where("user_id = ?", userID).Order("created_at desc").Find(&tokens).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "list tokens failed")
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// Create generates a new API token for the given user and returns the full token value (only shown once).
|
||||
func (tm *tokenManager) Create(userID uint, name string) (*model.Token, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, "", errors.New("token 名称不能为空")
|
||||
}
|
||||
|
||||
rawToken := model.TokenPrefix + tool.RandomString(32)
|
||||
|
||||
t := &model.Token{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Token: rawToken,
|
||||
}
|
||||
|
||||
if err := db.Default.Session().Create(t).Error; err != nil {
|
||||
return nil, "", errors.Wrap(err, "create token failed")
|
||||
}
|
||||
|
||||
return t, rawToken, nil
|
||||
}
|
||||
|
||||
// Delete removes a token by ID, only if it belongs to the given user.
|
||||
func (tm *tokenManager) Delete(userID uint, tokenID uint) error {
|
||||
result := db.Default.Session().
|
||||
Where("id = ? AND user_id = ?", tokenID, userID).
|
||||
Delete(&model.Token{})
|
||||
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(result.Error, "delete token failed")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("token 不存在或无权限删除")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify looks up a DB API token and returns a Session if valid.
|
||||
func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
|
||||
var t model.Token
|
||||
err := db.Default.Session().
|
||||
Where("token = ?", rawToken).
|
||||
Preload("User").
|
||||
Preload("User.Role").
|
||||
First(&t).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.New("无效的 API Token")
|
||||
}
|
||||
|
||||
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
|
||||
return nil, errors.New("API Token 已过期")
|
||||
}
|
||||
|
||||
// Update last_used_at asynchronously
|
||||
now := time.Now()
|
||||
go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck
|
||||
|
||||
session := &model.Session{
|
||||
UserID: t.User.ID,
|
||||
Username: t.User.Username,
|
||||
Role: t.User.Role.Name,
|
||||
RoleLabel: t.User.Role.Label,
|
||||
Permissions: t.User.Role.PermissionList(),
|
||||
LoginAt: now.Unix(),
|
||||
Token: rawToken,
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
@@ -11,8 +12,12 @@ import (
|
||||
|
||||
func AuthVerify() nf.HandlerFunc {
|
||||
tokenFn := func(c *nf.Ctx) (token string) {
|
||||
if token = c.Get("Authorization"); token != "" {
|
||||
return
|
||||
if raw := c.Get("Authorization"); raw != "" {
|
||||
// Strip "Bearer " prefix if present
|
||||
if strings.HasPrefix(raw, "Bearer ") {
|
||||
return strings.TrimPrefix(raw, "Bearer ")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
token = c.Cookies("ushare")
|
||||
return
|
||||
@@ -24,7 +29,18 @@ func AuthVerify() nf.HandlerFunc {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
session, err := controller.UserManager.Verify(token)
|
||||
var (
|
||||
session *model.Session
|
||||
err error
|
||||
)
|
||||
|
||||
// API tokens have the "ust_" prefix; session tokens do not.
|
||||
if strings.HasPrefix(token, model.TokenPrefix) {
|
||||
session, err = controller.TokenManager.Verify(token)
|
||||
} else {
|
||||
session, err = controller.UserManager.Verify(token)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
@@ -10,10 +15,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/spf13/viper"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Fetch() nf.HandlerFunc {
|
||||
@@ -116,3 +117,36 @@ func ShareUpload() nf.HandlerFunc {
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
|
||||
}
|
||||
}
|
||||
|
||||
// ShareAPIUpload handles one-step file upload via API token.
|
||||
// PUT /api/v1/upload/:filename
|
||||
// Accepts the raw file body and Content-Length header, returns the download code.
|
||||
func ShareAPIUpload() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
filename := strings.TrimSpace(c.Param("filename"))
|
||||
if filename == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "filename required"})
|
||||
}
|
||||
|
||||
size, err := cast.ToInt64E(c.Request.ContentLength)
|
||||
if err != nil || size <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Content-Length header required"})
|
||||
}
|
||||
|
||||
code, err := controller.MetaManager.New(size, filename, c.IP())
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "create upload failed"})
|
||||
}
|
||||
|
||||
_, _, err = controller.MetaManager.Write(code, 0, size-1, c.Request.Body)
|
||||
if err != nil {
|
||||
log.Error("handler.ShareAPIUpload: write error: %s", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "upload failed"})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{
|
||||
"status": 200,
|
||||
"data": map[string]string{"code": code},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
85
internal/handler/token.go
Normal file
85
internal/handler/token.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
)
|
||||
|
||||
func TokenList() 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"})
|
||||
}
|
||||
|
||||
tokens, err := controller.TokenManager.List(session.UserID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": tokens})
|
||||
}
|
||||
}
|
||||
|
||||
func TokenCreate() 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"})
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
|
||||
}
|
||||
|
||||
t, rawToken, err := controller.TokenManager.Create(session.UserID, req.Name)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{
|
||||
"data": map[string]any{
|
||||
"id": t.ID,
|
||||
"name": t.Name,
|
||||
"token": rawToken,
|
||||
"created_at": t.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TokenDelete() 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"})
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
|
||||
}
|
||||
|
||||
if req.ID == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "token id 不能为空"})
|
||||
}
|
||||
|
||||
if err := controller.TokenManager.Delete(session.UserID, req.ID); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
|
||||
}
|
||||
}
|
||||
19
internal/model/token.go
Normal file
19
internal/model/token.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Token is a personal API token for programmatic file upload.
|
||||
// Token values are prefixed with "ust_" to distinguish them from session tokens.
|
||||
type Token struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Token string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// TokenPrefix is the prefix for all API token values.
|
||||
const TokenPrefix = "ust_"
|
||||
Reference in New Issue
Block a user