Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
package authweb
import (
"errors"
"net/http"
"strings"
"time"
"91porn-server/common"
"91porn-server/common/constant"
"91porn-server/common/constant/redisconst"
"91porn-server/common/crypt"
"91porn-server/common/log"
"91porn-server/common/stderr"
"91porn-server/models/v/adminmod"
"91porn-server/models/v/ipwhitemod"
"91porn-server/web/webg"
"github.com/gin-gonic/gin"
)
var (
// 白名单,不需要验证token的api
whitelist = map[string]bool{
"/swagger": true,
"/api/web/admin/vid/pullFileInfoFromAws": true,
"/api/web/admin/vid/listDetail": true,
"/api/web/admin/vid/uploadStaticBatch": true,
"/api/web/channel/admin/login": true,
"/api/web/channel/admin/sms/captcha": true,
"/api/web/admin/cdn/sync": true,
"/api/web/admin/extend/syncServerFile": true,
"/api/web/admin/login": true,
"/api/web/admin/slogin": true,
"/api/web/admin/verify": true,
"/api/web/admin/vid/sec": true,
"/api/web/admin/vid/pms/sec": true,
"/api/web/admin/vid/pms/mt_sec": true,
"/api/web/admin/vid/sp/sec": true,
"/api/web/admin/vid/sp/lsjsec": true,
"/api/web/admin/vid/sp/m3u8sec": true,
"/api/web/admin/export/rechargeOrder": true,
"/api/web/admin/export/withdrawOrder": true,
"/api/web/admin/export/goldTurnover": true,
"/api/web/admin/export/videoIncome": true,
"/api/web/admin/export/orderStat": true,
"/api/web/admin/export/exchangecode": true,
"/api/web/district/agent/captcha": true,
"/api/web/district/agent/login": true,
"/api/web/admin/vid/syncNewVideo": true,
"/api/web/admin/newactivity/importModels": true,
"/api/web/admin/vid/spiderSyncSubmit": true,
"/api/web/admin/stats/export": true,
"/api/web/admin/ai/undress/callback": true,
"/api/web/admin/ai/changeface/callback": true,
"/api/web/admin/ai/change_face_img/callback": true,
"/api/web/admin/ai/image_to_video/callback": true,
"/api/web/admin/ai/text_to_image/callback": true,
"/api/web/admin/ai/text_to_novel/callback": true,
"/api/web/laosiji/post/search": true,
"/api/web/laosiji/post/detail": true,
"/api/web/laosiji/comics/search": true,
"/api/web/laosiji/comics/detail": true,
"/api/web/laosiji/novel/search": true,
"/api/web/laosiji/novel/detail": true,
}
InvalidTokenErr = errors.New(" invalid token err")
AccessForbidErr = errors.New(stderr.ErrAccessForbid.Msg())
noTokenMsg = gin.H{
"code": stderr.ErrNoToken,
"msg": stderr.ErrNoToken.Msg(),
}
)
const WarnTokenExpire = 10 * time.Minute
type ActType = string
const (
Admin ActType = "admin"
Channel ActType = "channel"
District ActType = "district"
)
type AdminRole = string
const (
JuShang AdminRole = "聚商"
NudeChatMerchant AdminRole = "裸聊商家"
)
func GetTokenSecret() string {
return webg.Conf.Base.JwtKey
}
type Claims struct {
Type ActType
Act string `json:"act"`
Role string `json:"role"`
CID string `json:"cid"`
}
type claimsWithExp struct {
Claims
Exp int64 `json:"exp"`
}
func genToken(claims *Claims) (string, error) {
c := claimsWithExp{Claims: *claims, Exp: time.Now().Add(redisconst.WebTokenExpire).Unix()}
secret := GetTokenSecret()
args, _ := common.JSONStruct2Map(c)
token, err := crypt.CreateToken(secret, args)
if err != nil {
log.Error("auth web GenToken error", log.Any("claims", claims), log.E(err))
}
return token, err
}
func GenAndSaveToken(claims *Claims) (string, error) {
token, err := genToken(claims)
if err != nil {
return "", err
}
if err = saveToken(claims.Type, claims.Act, token); err != nil {
return "", err
}
return token, nil
}
func ParseToken(token string) (*claimsWithExp, error) {
secret := GetTokenSecret()
claims, err := crypt.ParseToken(secret, token)
if err != nil {
return nil, err
}
c := claimsWithExp{}
return &c, common.Map2JSONStruct(&c, claims)
}
func tokenRedisKey(typ ActType, act string) string {
return redisconst.WebTokenKey(typ, act)
}
func saveToken(typ ActType, act string, token string) error {
key := tokenRedisKey(typ, act)
return webg.Redis.Set(key, token, redisconst.WebTokenExpire)
}
func RevokeDistrictToken(acts ...string) {
keys := make([]string, len(acts))
for i, act := range acts {
keys[i] = tokenRedisKey(District, act)
}
_, _ = webg.Redis.Del(keys...)
}
func RevokeChannelToken(acts ...string) {
keys := make([]string, len(acts))
for i, act := range acts {
keys[i] = tokenRedisKey(Channel, act)
}
_, _ = webg.Redis.Del(keys...)
}
func RevokeAdminToken(acts ...string) {
keys := make([]string, len(acts))
for i, act := range acts {
keys[i] = tokenRedisKey(Admin, act)
}
_, _ = webg.Redis.Del(keys...)
}
func auth(token string) (string, AdminRole, ActType, string, bool, error) {
claims, err := ParseToken(token)
if err != nil {
return "", "", "", "", false, InvalidTokenErr
}
act := claims.Act
typ := claims.Type
role := claims.Role
cid := claims.CID
exp := time.Unix(claims.Exp, 0)
redisKey := tokenRedisKey(typ, act)
redisToken, err := webg.Redis.Get(redisKey)
if err != nil || redisToken == nil {
return "", "", "", "", false, InvalidTokenErr
}
if token != *redisToken { //Redis token过期,或者错误
return "", "", "", "", false, InvalidTokenErr
}
now := time.Now()
if now.After(exp) { //token过期
return "", "", "", "", false, InvalidTokenErr
}
warn := exp.Sub(now) < WarnTokenExpire
return cid, role, typ, act, warn, nil
}
var accessForbidMsg = gin.H{
"code": stderr.ErrAccessForbid,
"msg": stderr.ErrAccessForbid.Msg(),
}
func Auth(ctx *gin.Context) {
for url, ok := range whitelist {
if ok && strings.HasPrefix(ctx.Request.URL.Path, url) {
return
}
}
//检查ip是否在白名单中
// ipFlag, err := webg.Redis.SISMember(constant.IPWhiteRedisKey, constant.CtxIP)
// if webg.Conf.EnableIPWhite.IsEnable && (err != nil || !ipFlag) {
if webg.Conf.EnableIPWhite.IsEnable {
iPWhite, err := ipwhitemod.FindOneByIp(ctx.ClientIP())
if err != nil || iPWhite.IP == "" {
ctx.AbortWithStatusJSON(http.StatusOK, accessForbidMsg)
return
}
}
var token string
t1 := ctx.Request.Header.Get("Authorization")
t2 := ctx.Query("token") //为兼容m3u8
if t1 != "" {
token = t1
}
if t2 != "" {
token = t2
}
if token == "" {
ctx.AbortWithStatusJSON(http.StatusOK, noTokenMsg)
return
}
cid, role, typ, act, warn, err := auth(token)
if err != nil {
ctx.Writer.Header().Set("Refresh-Authorization", "false")
ctx.AbortWithStatusJSON(http.StatusOK, gin.H{
"code": stderr.ErrAuthInvalid,
"msg": err.Error(),
})
return
}
// 检查管理员账号是否被删除或禁用
adm, err := adminmod.FindOneByName(act)
if err != nil || adm.ID.IsZero() || adm.HasLocked {
RevokeAdminToken(act)
ctx.Writer.Header().Set("Refresh-Authorization", "false")
ctx.AbortWithStatusJSON(http.StatusOK, gin.H{
"code": stderr.ErrAuthInvalid,
"msg": "账号已被禁用或删除",
})
return
}
if warn {
ctx.Writer.Header().Set("Refresh-Authorization", "true")
}
ctx.Set(constant.CtxAdminRole, role)
switch typ {
case District:
ctx.Set(constant.CtxDistrictName, act)
default:
ctx.Set(constant.CtxAdminAct, act)
switch role {
case JuShang:
if cid == "" {
ctx.AbortWithStatusJSON(http.StatusOK, gin.H{
"code": stderr.ErrAuthInvalid,
"msg": err.Error(),
})
}
ctx.Set(constant.CtxJuShangCID, cid)
if !strings.Contains(ctx.Request.URL.Path, "/jushang") && !strings.Contains(ctx.Request.URL.Path, "/api/web/admin/refresh") {
ctx.AbortWithStatusJSON(http.StatusOK,
gin.H{
"code": stderr.ErrAccessForbid,
"msg": "权限出错",
})
}
case NudeChatMerchant:
if cid == "" {
ctx.AbortWithStatusJSON(http.StatusOK, gin.H{
"code": stderr.ErrAuthInvalid,
"msg": err.Error(),
})
}
ctx.Set(constant.CtxNudeChatMerchant, cid)
}
}
}
func tokenAppRedisKey(uid uint64) string {
return redisconst.UserTokenKey(uid)
}
// RevokeTokenCache 吊销redis用户token
func RevokeTokenCache(uids ...uint64) {
keys := make([]string, len(uids))
for i, uid := range uids {
keys[i] = tokenAppRedisKey(uid)
}
_, _ = webg.Redis.Del(keys...)
}
func getWebTokenSecret() string {
return webg.Conf.Base.WebJwtKey
}
// https://tools.ietf.org/html/rfc7519#section-4.1
// See examples for how to use this with your own claim types
type WebClaims struct {
//用户UID
UID uint64 `json:"uid,omitempty"`
//The "exp" (expiration time) claim identifies the expiration time on
//or after which the JWT MUST NOT be accepted for processing. The
//processing of the "exp" claim requires that the current date/time
//MUST be before the expiration date/time listed in the "exp" claim.
//Implementers MAY provide for some small leeway, usually no more than
//a few minutes, to account for clock skew. Its value MUST be a number
//containing a NumericDate value. Use of this claim is OPTIONAL.
ExpiresAt int64 `json:"exp,omitempty"`
//The "iat" (issued at) claim identifies the time at which the JWT was
//issued. This claim can be used to determine the age of the JWT. Its
//value MUST be a number containing a NumericDate value. Use of this
//claim is OPTIONAL.
IssuedAt int64 `json:"iat,omitempty"`
}
func GenWebToken(claims WebClaims) (string, error) {
secret := getWebTokenSecret()
m, _ := common.JSONStruct2Map(claims)
token, err := crypt.CreateToken(secret, m)
if err != nil {
log.Error("GenWebToken error", log.Any("claims", claims), log.E(err))
}
return token, err
}
// ParseWebClaims
// 如果token过期会返回error
func ParseWebClaims(token string) (WebClaims, error) {
secret := getWebTokenSecret()
claims, err := crypt.ParseToken(secret, token)
if err != nil {
return WebClaims{}, err
}
webClaims := WebClaims{}
return webClaims, common.Map2JSONStruct(&webClaims, claims)
}
@@ -0,0 +1,51 @@
package checkPermission
import (
"net/http"
"strings"
"time"
"91porn-server/common"
"github.com/gin-gonic/gin"
"github.com/storyicon/grbac"
)
// 白名单,不需要验证token的api
var whitelist = map[string]bool{
"/swagger": true,
}
func QueryRolesByHeaders(c *gin.Context) (roles []string, err error) {
role, _ := common.GetAdminRole(c)
roles = append(roles, role)
return roles, err
}
var rbac *grbac.Controller
func init() {
var err error
rbac, err = grbac.New(grbac.WithJSON("config/rules.json", 10*time.Minute))
if err != nil {
panic(err)
}
}
func CheckPermission(c *gin.Context) {
for url, ok := range whitelist {
if ok && strings.HasPrefix(c.Request.URL.Path, url) {
return
}
}
roles, _ := QueryRolesByHeaders(c)
state, _ := rbac.IsRequestGranted(c.Request, roles)
if !state.IsGranted() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"msg": "没有操作权限",
})
return
}
}
+29
View File
@@ -0,0 +1,29 @@
package limitHandler
import (
"91porn-server/common"
"91porn-server/common/stderr"
"91porn-server/web/webg"
"fmt"
"github.com/gin-gonic/gin"
"time"
)
// FilterRequestByExport 限制用户短时间内的导出请求次数,limit决定限制时间
func FilterRequestByExport() gin.HandlerFunc {
return func(ctx *gin.Context) {
manager, err := common.GetAdminAct(ctx)
if err != nil {
common.ServeJSON(ctx, stderr.AdminIDErr, err.Error())
ctx.Abort()
return
}
key := fmt.Sprintf("export_excel:%v", manager)
if webg.Redis.IsExist(key) {
common.ServeJSON(ctx, stderr.VisitLimit, err)
ctx.Abort()
return
}
go func() { _ = webg.Redis.Set(key, "-", time.Second*3) }()
}
}
+105
View File
@@ -0,0 +1,105 @@
package limitHandler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"91porn-server/common"
"91porn-server/common/constant"
"91porn-server/common/httputil"
"91porn-server/common/stderr"
"91porn-server/web/webg"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
)
var limitURI = []string{
"/api/web/admin/login",
"/api/web/admin/slogin",
"/api/web/admin/verify",
}
var (
tgBotToken = "2013237388:AAFwvKvEmQfEvcI7wt_64Zkp9KPfgHo8sow"
tgGroupId = "-1003735177636"
)
var (
limitDuration = 3 * time.Minute
limitCount = 5
)
var memCache = cache.New(1*time.Minute, 10*time.Minute)
// LoginLimit 登陆频率限制
func LoginLimit(ctx *gin.Context) {
path := ctx.Request.URL.Path
// 检查是否需要限制频率
check := false
for _, p := range limitURI {
if strings.HasPrefix(path, p) {
check = true
}
}
if !check {
return
}
// 测试环境不限制
if webg.Conf.Base.Env != constant.ProdEnv {
return
}
// 获取KEY:只按 path + 管理员账号(name),不含 IP
// (对方会伪造 IP,若 key 含 IP 则每次换 IP 就重置计数,导致限流被绕过一直重试)
ip := ctx.ClientIP() // ip 仅用于超限预警展示,不参与限流 key
key := fmt.Sprintf("web-limit:%s", path)
// 检查参数值
var params map[string]interface{}
if ctx.Request.Method == http.MethodPost {
raw, _ := ctx.GetRawData()
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
if err := json.Unmarshal(raw, &params); err == nil {
if a, ok := params["name"]; ok { // 做登陆频率限制只检查管理员账号
key += ":" + a.(string)
}
}
}
// 检查频率
aCount, ok := memCache.Get(key)
if !ok {
memCache.Set(key, 1, limitDuration)
return
}
count, ok2 := aCount.(int)
if !ok2 {
memCache.Set(key, 1, limitDuration)
return
}
count++
defer func() { memCache.Set(key, count, limitDuration) }()
if count > limitCount {
// 超出限制
go func() {
// 预警
message := fmt.Sprintf("[91Porn]\n登陆请求频率过高!\n时间: %s\n请求地址: %s\n请求参数: %+v\nIP: %s\n错误次数: %d", time.Now().Format("2006-01-02 15:04:05"), path, params, ip, count)
// 请求地址/参数为不可信输入,去掉非法 UTF-8 字节;不用 parse_mode,避免 Markdown 把内容解析成实体后报错
message = strings.ToValidUTF8(message, "")
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", tgBotToken)
req := map[string]interface{}{
"chat_id": tgGroupId,
"text": message,
}
resp, _ := httputil.DefaultClientPostJson(url, nil, req)
_ = resp.Body.Close()
}()
ctx.Abort()
common.ServeJSON(ctx, stderr.ErrReqForbidden, nil)
return
}
ctx.Next()
}