Files
PrivHunterAI/internal/httpapi/server.go
T

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": "密码已修改,请重新登录"})
}