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)) } }