init push

This commit is contained in:
2026-05-21 19:52:45 +08:00
commit e3f75311ab
1280 changed files with 179173 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package middleware
import (
"context"
"github.com/chaitin/panda-wiki/domain"
)
type APITokenRepository interface {
GetByTokenWithCache(ctx context.Context, token string) (*domain.APIToken, error)
}

View File

@@ -0,0 +1,29 @@
package middleware
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/repo/pg"
)
type AuthMiddleware interface {
Authorize(next echo.HandlerFunc) echo.HandlerFunc
ValidateUserRole(role consts.UserRole) echo.MiddlewareFunc
ValidateKBUserPerm(role consts.UserKBPermission) echo.MiddlewareFunc
ValidateLicenseEdition(edition ...consts.LicenseEdition) echo.MiddlewareFunc
MustGetUserID(c echo.Context) (string, bool)
}
func NewAuthMiddleware(config *config.Config, logger *log.Logger, userAccessRepo *pg.UserAccessRepository, apiTokenRepo *pg.APITokenRepo) (AuthMiddleware, error) {
switch config.Auth.Type {
case "jwt":
return NewJWTMiddleware(config, logger, userAccessRepo, apiTokenRepo), nil
default:
return nil, fmt.Errorf("invalid auth type: %s", config.Auth.Type)
}
}

285
backend/middleware/jwt.go Normal file
View File

@@ -0,0 +1,285 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"slices"
"strings"
"github.com/golang-jwt/jwt/v5"
echoMiddleware "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/repo/pg"
)
type JWTMiddleware struct {
config *config.Config
jwtMiddleware echo.MiddlewareFunc
logger *log.Logger
userAccessRepo *pg.UserAccessRepository
apiTokenRepo *pg.APITokenRepo
}
func NewJWTMiddleware(config *config.Config, logger *log.Logger, userAccessRepo *pg.UserAccessRepository, apiTokenRepo *pg.APITokenRepo) *JWTMiddleware {
jwtMiddleware := echoMiddleware.WithConfig(echoMiddleware.Config{
SigningKey: []byte(config.Auth.JWT.Secret),
ErrorHandler: func(c echo.Context, err error) error {
logger.Error("jwt auth failed", log.Error(err))
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
},
})
return &JWTMiddleware{
config: config,
jwtMiddleware: jwtMiddleware,
logger: logger.WithModule("middleware.jwt"),
userAccessRepo: userAccessRepo,
apiTokenRepo: apiTokenRepo,
}
}
func (m *JWTMiddleware) Authorize(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
authHeader := c.Request().Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
if !strings.Contains(token, ".") {
return m.validateAPIToken(c, token, next)
}
}
return m.jwtMiddleware(func(c echo.Context) error {
if userID, ok := m.MustGetUserID(c); ok {
ctx := context.WithValue(c.Request().Context(), domain.CtxAuthInfoKey, &domain.CtxAuthInfo{
IsToken: false,
Permission: consts.UserKBPermissionNull,
UserId: userID,
})
req := c.Request().WithContext(ctx)
c.SetRequest(req)
m.userAccessRepo.UpdateAccessTime(userID)
}
return next(c)
})(c)
}
}
// validateAPIToken validates API token and sets user context
func (m *JWTMiddleware) validateAPIToken(c echo.Context, token string, next echo.HandlerFunc) error {
if m.apiTokenRepo == nil {
m.logger.Debug("API token repository not available")
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
apiToken, err := m.apiTokenRepo.GetByTokenWithCache(c.Request().Context(), token)
if err != nil || apiToken == nil {
m.logger.Error("failed to get API token", log.Error(err))
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
ctx := context.WithValue(c.Request().Context(), domain.CtxAuthInfoKey, &domain.CtxAuthInfo{
IsToken: true,
Permission: apiToken.Permission,
UserId: apiToken.UserID,
KBId: apiToken.KbId,
})
req := c.Request().WithContext(ctx)
c.SetRequest(req)
return next(c)
}
func (m *JWTMiddleware) ValidateUserRole(role consts.UserRole) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
authInfo := domain.GetAuthInfoFromCtx(c.Request().Context())
if authInfo == nil {
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
if authInfo.IsToken {
// token 视为普通用户 没有管理员相关权限
if role == consts.UserRoleAdmin {
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "token not support admin role",
})
}
} else {
valid, err := m.userAccessRepo.ValidateRole(authInfo.UserId, role)
if err != nil || !valid {
m.logger.Error("ValidateRole check", log.Any("user_id", authInfo.UserId), log.Any("valid", valid))
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "StatusForbidden ValidateRole",
})
}
}
return next(c)
}
}
}
func (m *JWTMiddleware) ValidateKBUserPerm(perm consts.UserKBPermission) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
authInfo := domain.GetAuthInfoFromCtx(c.Request().Context())
if authInfo == nil {
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
kbId, _ := GetKbID(c)
if authInfo.IsToken {
if authInfo.KBId != kbId {
m.logger.Error("ValidateKBUserPerm ValidateTokenKBPerm kbId", "authInfo.KBId", authInfo.KBId, "kbId", kbId)
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateTokenKBPerm kbId",
})
}
if perm == consts.UserKBPermissionNotNull {
if authInfo.Permission == consts.UserKBPermissionNull {
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateTokenKBPerm",
})
}
} else if authInfo.Permission != consts.UserKBPermissionFullControl && authInfo.Permission != perm {
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateTokenKBPerm",
})
}
} else {
// 正常用户请求
valid, err := m.userAccessRepo.ValidateKBPerm(kbId, authInfo.UserId, perm)
if err != nil || !valid {
if err != nil {
m.logger.Error("ValidateKBUserPerm ValidateKBPerm failed", log.Error(err))
} else {
m.logger.Info("ValidateKBUserPerm ValidateKBPerm failed", log.String("kb_id", kbId), log.String("user_id", authInfo.UserId))
}
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateKBPerm",
})
}
}
return next(c)
}
}
}
func (m *JWTMiddleware) ValidateLicenseEdition(needEditions ...consts.LicenseEdition) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
edition, ok := c.Get("edition").(consts.LicenseEdition)
if !ok {
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateLicenseEdition",
})
}
if !slices.Contains(needEditions, edition) {
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateLicenseEdition",
})
}
return next(c)
}
}
}
func (m *JWTMiddleware) MustGetUserID(c echo.Context) (string, bool) {
user, ok := c.Get("user").(*jwt.Token)
if !ok || user == nil {
return "", false
}
claims, ok := user.Claims.(jwt.MapClaims)
if !ok {
return "", false
}
id, ok := claims["id"].(string)
return id, ok
}
func GetKbID(c echo.Context) (string, error) {
switch c.Request().Method {
case http.MethodGet, http.MethodDelete:
var kbId string
if strings.Contains(c.Request().URL.Path, "knowledge_base") {
kbId = c.QueryParam("id")
if kbId != "" {
return kbId, nil
}
}
kbId = c.QueryParam("kb_id")
if kbId != "" {
return kbId, nil
}
return "", nil
case http.MethodPost, http.MethodPatch, http.MethodPut:
bodyBytes, err := io.ReadAll(c.Request().Body)
if err != nil {
return "", err
}
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
var m map[string]interface{}
if err := json.Unmarshal(bodyBytes, &m); err == nil {
if strings.Contains(c.Request().URL.Path, "knowledge_base") {
if id, exists := m["id"].(string); exists && id != "" {
return id, nil
}
}
if id, exists := m["kb_id"].(string); exists && id != "" {
return id, nil
}
}
return "", nil
default:
return "", nil
}
}

View File

@@ -0,0 +1,10 @@
package middleware
import "github.com/google/wire"
var ProviderSet = wire.NewSet(
NewAuthMiddleware,
NewShareAuthMiddleware,
NewReadonlyMiddleware,
NewSessionMiddleware,
)

View File

@@ -0,0 +1,57 @@
package middleware
import (
"os"
"strings"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
)
type ReadOnlyMiddleware struct {
logger *log.Logger
}
func NewReadonlyMiddleware(logger *log.Logger) *ReadOnlyMiddleware {
return &ReadOnlyMiddleware{
logger: logger.WithModule("middleware.readonly"),
}
}
// echo read only middleware, if request method is not get, return 403 forbidden
func (readonly *ReadOnlyMiddleware) ReadOnly(next echo.HandlerFunc) echo.HandlerFunc {
readonlyMode := os.Getenv("READONLY") == "1" || strings.ToLower(os.Getenv("READONLY")) == "true"
return func(c echo.Context) error {
if !readonlyMode {
return next(c)
}
path := c.Request().URL.Path
// only check /api/v1 path
if strings.HasPrefix(path, "/api/v1") {
method := c.Request().Method
// skip get
// skip /api/v1/user/login
if !isReadOnlyMethod(method) && path != "/api/v1/user/login" {
readonly.logger.Warn("readonly mode rejected request",
"method", method,
"path", path)
return c.JSON(503, domain.PWResponse{
Success: false,
Message: "API is in read-only mode",
})
}
}
return next(c)
}
}
func isReadOnlyMethod(method string) bool {
switch method {
case "GET", "HEAD", "OPTIONS":
return true
default:
return false
}
}

View File

@@ -0,0 +1,67 @@
package middleware
import (
"context"
"net/http"
"time"
"github.com/boj/redistore"
"github.com/google/uuid"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/store/cache"
)
const (
SessionKey = "SessionKey"
)
type SessionMiddleware struct {
logger *log.Logger
store *redistore.RediStore
}
func NewSessionMiddleware(logger *log.Logger, config *config.Config, cache *cache.Cache) (*SessionMiddleware, error) {
secretKey, err := cache.GetOrSet(context.Background(), SessionKey, uuid.New().String(), time.Duration(0))
if err != nil {
logger.Error("session store create secret key failed: %v", log.Error(err))
return nil, err
}
store, err := redistore.NewRediStore(
10,
"tcp",
config.Redis.Addr,
"",
config.Redis.Password,
[]byte(secretKey.(string)),
)
if err != nil {
logger.Error("init session store failed: %v", log.Error(err))
return nil, err
}
store.Options = &sessions.Options{
Path: "/",
MaxAge: 30 * 86400,
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
}
return &SessionMiddleware{
logger: logger.WithModule("middleware.session"),
store: store,
}, nil
}
func (s *SessionMiddleware) Session() echo.MiddlewareFunc {
return session.MiddlewareWithConfig(session.Config{
Store: s.store,
})
}

View File

@@ -0,0 +1,127 @@
package middleware
import (
"net/http"
"github.com/getsentry/sentry-go"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareAuthMiddleware struct {
logger *log.Logger
kbUsecase *usecase.KnowledgeBaseUsecase
}
func NewShareAuthMiddleware(logger *log.Logger, kbUsecase *usecase.KnowledgeBaseUsecase) *ShareAuthMiddleware {
return &ShareAuthMiddleware{
logger: logger.WithModule("middleware.share_auth"),
kbUsecase: kbUsecase,
}
}
func (h *ShareAuthMiddleware) CheckForbidden(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
h.logger.Error("kb_id is empty")
return c.JSON(http.StatusBadRequest, domain.PWResponse{
Success: false,
Message: "kb_id is required",
})
}
kb, err := h.kbUsecase.GetKnowledgeBase(c.Request().Context(), kbID)
if err != nil {
h.logger.Error("get knowledge base failed", log.String("kb_id", kbID), log.Error(err))
sentry.CaptureException(err)
return c.JSON(http.StatusInternalServerError, domain.PWResponse{
Success: false,
Message: "failed to get knowledge base detail",
})
}
if kb.AccessSettings.IsForbidden {
h.logger.Warn("access forbidden", log.String("kb_id", kbID))
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "access is forbidden",
})
}
return next(c)
}
}
func (h *ShareAuthMiddleware) Authorize(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
h.logger.Error("kb_id is empty")
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
kb, err := h.kbUsecase.GetKnowledgeBase(c.Request().Context(), kbID)
if err != nil {
h.logger.Error("get knowledge base failed", log.String("kb_id", kbID), log.Error(err))
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
if kb.AccessSettings.IsForbidden {
h.logger.Warn("access forbidden", log.String("kb_id", kbID))
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "access is forbidden",
})
}
// 未开启认证
if !kb.AccessSettings.EnterpriseAuth.Enabled && !kb.AccessSettings.SimpleAuth.Enabled {
return next(c)
}
sess, err := session.Get(domain.SessionName, c)
if err != nil {
h.logger.Error("session get failed", log.Error(err))
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
KbIDSess, ok := sess.Values["kb_id"].(string)
if !ok || kbID == "" || KbIDSess != kb.ID {
h.logger.Error("kb_id valid failed", log.Error(err))
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
// 企业认证
if kb.AccessSettings.EnterpriseAuth.Enabled {
userId, ok := sess.Values["user_id"].(uint)
if !ok || userId == 0 {
h.logger.Error("session user_id get failed", log.Error(err))
return c.JSON(http.StatusUnauthorized, domain.PWResponse{
Success: false,
Message: "Unauthorized",
})
}
c.Set("user_id", userId)
return next(c)
}
return next(c)
}
}