Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
@@ -0,0 +1,293 @@
package auth
import (
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"image"
"image/color"
"image/draw"
"image/png"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"ymhut-box/server/feedback-mailer/internal/config"
)
const (
sessionCookie = "ymhut_feedback_session"
captchaTTL = 5 * time.Minute
sessionTTL = 12 * time.Hour
)
type Service struct {
cfg *config.Config
mu sync.Mutex
captchas map[string]captchaEntry
sessions map[string]sessionEntry
}
type captchaEntry struct {
Answer string
ExpiresAt time.Time
}
type sessionEntry struct {
ExpiresAt time.Time
CSRFToken string
}
type Captcha struct {
ID string
ImagePNG []byte
}
func NewService(cfg *config.Config) *Service {
return &Service{
cfg: cfg,
captchas: map[string]captchaEntry{},
sessions: map[string]sessionEntry{},
}
}
func (s *Service) NewCaptcha() (Captcha, error) {
answer := randomDigits(5)
id := randomToken(16)
imageBytes, err := renderCaptcha(answer)
if err != nil {
return Captcha{}, err
}
s.mu.Lock()
s.cleanupLocked()
s.captchas[id] = captchaEntry{Answer: answer, ExpiresAt: time.Now().Add(captchaTTL)}
s.mu.Unlock()
return Captcha{ID: id, ImagePNG: imageBytes}, nil
}
func (s *Service) Login(password, captchaID, captchaAnswer string) (string, string, bool) {
if !s.consumeCaptcha(captchaID, captchaAnswer) {
return "", "", false
}
if !s.VerifyPassword(password) {
return "", "", false
}
sessionID := randomToken(32)
csrf := randomToken(32)
s.mu.Lock()
s.cleanupLocked()
s.sessions[sessionID] = sessionEntry{ExpiresAt: time.Now().Add(sessionTTL), CSRFToken: csrf}
s.mu.Unlock()
return sessionID, csrf, true
}
func (s *Service) Logout(c *gin.Context) {
sessionID, _ := c.Cookie(sessionCookie)
s.mu.Lock()
delete(s.sessions, sessionID)
s.mu.Unlock()
clearCookie(c)
}
func (s *Service) RequireAuth(c *gin.Context) {
sessionID, err := c.Cookie(sessionCookie)
if err != nil || sessionID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
c.Abort()
return
}
csrf, ok := s.SessionCSRF(sessionID)
if !ok {
clearCookie(c)
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
c.Abort()
return
}
c.Set("csrf", csrf)
c.Next()
}
func (s *Service) RequireCSRF(c *gin.Context) {
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
c.Next()
return
}
expected, _ := c.Get("csrf")
actual := c.GetHeader("X-CSRF-Token")
if expected == nil || actual == "" || subtle.ConstantTimeCompare([]byte(expected.(string)), []byte(actual)) != 1 {
c.JSON(http.StatusForbidden, gin.H{"ok": false, "error": "CSRF_INVALID", "message": "Invalid CSRF token"})
c.Abort()
return
}
c.Next()
}
func (s *Service) SetSessionCookie(c *gin.Context, sessionID string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, sessionID, int(sessionTTL.Seconds()), "/", "", false, true)
}
func (s *Service) SessionCSRF(sessionID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupLocked()
session, ok := s.sessions[sessionID]
if !ok {
return "", false
}
return session.CSRFToken, true
}
func (s *Service) VerifyPassword(password string) bool {
password = strings.TrimSpace(password)
if password == "" {
return false
}
hash := strings.TrimSpace(s.cfg.AdminPasswordHash)
if hash != "" && verifyBcrypt(hash, password) {
return true
}
plain := strings.TrimSpace(s.cfg.AdminPassword)
return plain != "" && subtle.ConstantTimeCompare([]byte(plain), []byte(password)) == 1
}
func (s *Service) consumeCaptcha(id, answer string) bool {
id = strings.TrimSpace(id)
answer = strings.TrimSpace(answer)
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupLocked()
entry, ok := s.captchas[id]
if ok {
delete(s.captchas, id)
}
if !ok || time.Now().After(entry.ExpiresAt) {
return false
}
return subtle.ConstantTimeCompare([]byte(strings.ToLower(entry.Answer)), []byte(strings.ToLower(answer))) == 1
}
func (s *Service) cleanupLocked() {
now := time.Now()
for id, entry := range s.captchas {
if now.After(entry.ExpiresAt) {
delete(s.captchas, id)
}
}
for id, entry := range s.sessions {
if now.After(entry.ExpiresAt) {
delete(s.sessions, id)
}
}
}
func verifyBcrypt(hash, password string) bool {
candidates := []string{hash}
if strings.HasPrefix(hash, "$2y$") {
candidates = append(candidates, "$2a$"+strings.TrimPrefix(hash, "$2y$"))
}
for _, candidate := range candidates {
if bcrypt.CompareHashAndPassword([]byte(candidate), []byte(password)) == nil {
return true
}
}
return false
}
func clearCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, "", -1, "/", "", false, true)
}
func randomDigits(count int) string {
data := make([]byte, count)
if _, err := rand.Read(data); err != nil {
return "12345"
}
var builder strings.Builder
for _, value := range data {
builder.WriteByte('0' + value%10)
}
return builder.String()
}
func randomToken(bytesLen int) string {
data := make([]byte, bytesLen)
if _, err := rand.Read(data); err != nil {
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(data)
}
func renderCaptcha(answer string) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, 180, 64))
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{245, 248, 252, 255}}, image.Point{}, draw.Src)
for i := 0; i < 24; i++ {
x := (i*37 + 13) % 180
y := (i*19 + 7) % 64
img.Set(x, y, color.RGBA{102, 120, 145, 255})
}
for index, digit := range answer {
drawDigit(img, int(digit-'0'), 18+index*32, 13, color.RGBA{28, 72, 130, 255})
}
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
var segments = [10][7]bool{
{true, true, true, true, true, true, false},
{false, true, true, false, false, false, false},
{true, true, false, true, true, false, true},
{true, true, true, true, false, false, true},
{false, true, true, false, false, true, true},
{true, false, true, true, false, true, true},
{true, false, true, true, true, true, true},
{true, true, true, false, false, false, false},
{true, true, true, true, true, true, true},
{true, true, true, true, false, true, true},
}
func drawDigit(img *image.RGBA, digit, x, y int, col color.Color) {
if digit < 0 || digit > 9 {
return
}
thick := 4
width := 22
height := 36
drawSegment := func(rect image.Rectangle) {
draw.Draw(img, rect, &image.Uniform{col}, image.Point{}, draw.Src)
}
if segments[digit][0] {
drawSegment(image.Rect(x+thick, y, x+width-thick, y+thick))
}
if segments[digit][1] {
drawSegment(image.Rect(x+width-thick, y+thick, x+width, y+height/2))
}
if segments[digit][2] {
drawSegment(image.Rect(x+width-thick, y+height/2, x+width, y+height-thick))
}
if segments[digit][3] {
drawSegment(image.Rect(x+thick, y+height-thick, x+width-thick, y+height))
}
if segments[digit][4] {
drawSegment(image.Rect(x, y+height/2, x+thick, y+height-thick))
}
if segments[digit][5] {
drawSegment(image.Rect(x, y+thick, x+thick, y+height/2))
}
if segments[digit][6] {
drawSegment(image.Rect(x+thick, y+height/2-thick/2, x+width-thick, y+height/2+thick/2))
}
}