@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user