mirror of
https://github.com/Ed1s0nZ/PrivHunterAI.git
synced 2026-10-27 19:10:55 +00:00
253 lines
6.6 KiB
Go
253 lines
6.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"yuequanScan/internal/auth"
|
|
"yuequanScan/internal/store"
|
|
)
|
|
|
|
type Server struct {
|
|
ScannerStatus func() map[string]any
|
|
Store *store.Store
|
|
Auth *auth.Service
|
|
SecureCookie bool
|
|
attemptsMu sync.Mutex
|
|
attempts map[string]attempt
|
|
}
|
|
type attempt struct {
|
|
Count int
|
|
Until time.Time
|
|
}
|
|
|
|
func New(db *store.Store, secure bool) *Server {
|
|
return &Server{Store: db, Auth: &auth.Service{Store: db}, SecureCookie: secure, attempts: map[string]attempt{}}
|
|
}
|
|
func fail(c *gin.Context, status int, message string) {
|
|
c.AbortWithStatusJSON(status, gin.H{"error": message})
|
|
}
|
|
func (s *Server) Router() *gin.Engine {
|
|
r := gin.New()
|
|
r.Use(gin.Recovery(), func(c *gin.Context) {
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("X-Frame-Options", "DENY")
|
|
c.Header("Referrer-Policy", "no-referrer")
|
|
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
|
c.Next()
|
|
})
|
|
r.SetTrustedProxies(nil)
|
|
a := r.Group("/api")
|
|
a.Use(func(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Next() })
|
|
a.GET("/auth/status", s.status)
|
|
a.POST("/auth/setup", s.sameOrigin, s.setup)
|
|
a.POST("/auth/login", s.sameOrigin, s.login)
|
|
p := a.Group("")
|
|
p.Use(s.requireSession)
|
|
p.GET("/auth/me", s.me)
|
|
p.POST("/auth/logout", s.logout)
|
|
p.POST("/auth/password", s.password)
|
|
p.GET("/stats", s.stats)
|
|
p.GET("/dashboard", s.dashboard)
|
|
p.GET("/findings", s.findings)
|
|
p.GET("/findings/:id", s.finding)
|
|
p.GET("/export", s.export)
|
|
admin := p.Group("")
|
|
admin.Use(s.requireAdmin)
|
|
admin.POST("/findings/bulk-review", s.bulkReview)
|
|
admin.PATCH("/findings/:id", s.review)
|
|
admin.DELETE("/findings/:id", s.deleteFinding)
|
|
admin.GET("/users", s.users)
|
|
admin.POST("/users", s.createUser)
|
|
admin.PATCH("/users/:id", s.updateUser)
|
|
admin.GET("/audit", s.audit)
|
|
admin.GET("/scanner/status", func(c *gin.Context) {
|
|
if s.ScannerStatus == nil {
|
|
c.JSON(200, gin.H{})
|
|
return
|
|
}
|
|
c.JSON(200, s.ScannerStatus())
|
|
})
|
|
admin.GET("/settings", s.settings)
|
|
admin.PUT("/settings", s.saveSettings)
|
|
return r
|
|
}
|
|
func (s *Server) sameOrigin(c *gin.Context) {
|
|
if origin := c.GetHeader("Origin"); origin != "" {
|
|
u, e := url.Parse(origin)
|
|
if e != nil || u.Host != c.Request.Host || (u.Scheme != "http" && u.Scheme != "https") {
|
|
fail(c, 403, "请求来源不允许")
|
|
return
|
|
}
|
|
}
|
|
if site := c.GetHeader("Sec-Fetch-Site"); site == "cross-site" {
|
|
fail(c, 403, "请求来源不允许")
|
|
return
|
|
}
|
|
}
|
|
func (s *Server) requireSession(c *gin.Context) {
|
|
token, e := c.Cookie("privhunter_session")
|
|
if e != nil {
|
|
fail(c, 401, "请先登录")
|
|
return
|
|
}
|
|
v, e := s.Auth.Authenticate(token)
|
|
if e != nil {
|
|
fail(c, 401, "会话已过期,请重新登录")
|
|
return
|
|
}
|
|
c.Set("session", v)
|
|
if c.Request.Method != "GET" && c.Request.Method != "HEAD" {
|
|
s.sameOrigin(c)
|
|
if c.IsAborted() {
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(c.GetHeader("X-CSRF-Token")), []byte(v.CSRF)) != 1 {
|
|
fail(c, 403, "请求校验失败")
|
|
return
|
|
}
|
|
}
|
|
c.Next()
|
|
}
|
|
func session(c *gin.Context) auth.Session { return c.MustGet("session").(auth.Session) }
|
|
func (s *Server) requireAdmin(c *gin.Context) {
|
|
if session(c).User.Role != "admin" {
|
|
fail(c, 403, "需要管理员权限")
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
func id(c *gin.Context) (int64, bool) {
|
|
n, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if e != nil || n < 1 {
|
|
fail(c, 400, "无效记录编号")
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
func page(c *gin.Context) int {
|
|
n, _ := strconv.Atoi(c.Query("page"))
|
|
if n < 1 {
|
|
n = 1
|
|
}
|
|
return n
|
|
}
|
|
func (s *Server) status(c *gin.Context) {
|
|
n, e := s.Store.UserCount()
|
|
if e != nil {
|
|
fail(c, 500, "数据库不可用")
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"setupRequired": n == 0})
|
|
}
|
|
func (s *Server) me(c *gin.Context) {
|
|
v := session(c)
|
|
c.JSON(200, gin.H{"user": v.User, "csrf": v.CSRF})
|
|
}
|
|
func (s *Server) setup(c *gin.Context) {
|
|
var v struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if c.ShouldBindJSON(&v) != nil {
|
|
fail(c, 400, "无效请求")
|
|
return
|
|
}
|
|
if e := auth.Validate(v.Username, v.Password); e != nil {
|
|
fail(c, 400, e.Error())
|
|
return
|
|
}
|
|
h, e := auth.Hash(v.Password)
|
|
if e != nil {
|
|
fail(c, 500, "初始化失败")
|
|
return
|
|
}
|
|
ok, e := s.Store.Bootstrap(v.Username, h)
|
|
if e != nil {
|
|
fail(c, 500, "初始化失败")
|
|
return
|
|
}
|
|
if !ok {
|
|
fail(c, 409, "系统已初始化")
|
|
return
|
|
}
|
|
s.Store.Log(v.Username, "setup", "administrator")
|
|
c.JSON(201, gin.H{"message": "初始化完成,请登录"})
|
|
}
|
|
func (s *Server) login(c *gin.Context) {
|
|
var v struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if c.ShouldBindJSON(&v) != nil || len(v.Password) > 72 {
|
|
fail(c, 400, "无效请求")
|
|
return
|
|
}
|
|
key := c.ClientIP()
|
|
now := time.Now()
|
|
s.attemptsMu.Lock()
|
|
a := s.attempts[key]
|
|
if now.Before(a.Until) && a.Count >= 10 {
|
|
s.attemptsMu.Unlock()
|
|
fail(c, 429, "尝试次数过多,请稍后再试")
|
|
return
|
|
}
|
|
if !now.Before(a.Until) {
|
|
a = attempt{Until: now.Add(5 * time.Minute)}
|
|
}
|
|
a.Count++
|
|
s.attempts[key] = a
|
|
if len(s.attempts) > 1024 {
|
|
for k, v := range s.attempts {
|
|
if now.After(v.Until) {
|
|
delete(s.attempts, k)
|
|
}
|
|
}
|
|
}
|
|
s.attemptsMu.Unlock()
|
|
token, vsession, e := s.Auth.Login(v.Username, v.Password)
|
|
if e != nil {
|
|
fail(c, 401, "用户名或密码错误")
|
|
return
|
|
}
|
|
s.attemptsMu.Lock()
|
|
delete(s.attempts, key)
|
|
s.attemptsMu.Unlock()
|
|
c.SetSameSite(http.SameSiteStrictMode)
|
|
c.SetCookie("privhunter_session", token, 43200, "/", "", s.SecureCookie, true)
|
|
s.Store.Log(vsession.User.Username, "login", "session")
|
|
c.JSON(200, gin.H{"user": vsession.User, "csrf": vsession.CSRF})
|
|
}
|
|
func (s *Server) logout(c *gin.Context) {
|
|
token, _ := c.Cookie("privhunter_session")
|
|
if s.Auth.Logout(token) != nil {
|
|
fail(c, 500, "退出失败")
|
|
return
|
|
}
|
|
c.SetSameSite(http.SameSiteStrictMode)
|
|
c.SetCookie("privhunter_session", "", -1, "/", "", s.SecureCookie, true)
|
|
c.JSON(200, gin.H{"message": "已退出"})
|
|
}
|
|
func (s *Server) password(c *gin.Context) {
|
|
var v struct {
|
|
Current string `json:"current"`
|
|
Password string `json:"password"`
|
|
}
|
|
if c.ShouldBindJSON(&v) != nil {
|
|
fail(c, 400, "无效请求")
|
|
return
|
|
}
|
|
if e := s.Auth.ChangePassword(session(c).User.ID, v.Current, v.Password); e != nil {
|
|
fail(c, 400, e.Error())
|
|
return
|
|
}
|
|
s.Store.Log(session(c).User.Username, "password_changed", "self")
|
|
c.JSON(200, gin.H{"message": "密码已修改,请重新登录"})
|
|
}
|