Files

353 lines
9.1 KiB
Go
Raw Permalink Normal View History

package api
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"net/http"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
const (
captchaTTL = 2 * time.Minute
captchaKeyPrefix = "pargar:captcha:"
captchaRateLimit int64 = 60
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
captchaMinLen = 4
captchaMaxLen = 5
captchaImgW = 160
captchaImgH = 52
)
var (
errCaptchaUnavailable = errors.New("captcha unavailable")
errCaptchaRequired = errors.New("captcha required")
errCaptchaInvalid = errors.New("captcha invalid")
// 5×7 bitmap glyphs for A–Z and 0–9 (row-major, MSB = left).
captchaGlyphs = map[byte][7]byte{
'A': {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11},
'B': {0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E},
'C': {0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E},
'D': {0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E},
'E': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F},
'F': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10},
'G': {0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E},
'H': {0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11},
'J': {0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E},
'K': {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11},
'L': {0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F},
'M': {0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11},
'N': {0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11},
'P': {0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10},
'Q': {0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D},
'R': {0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11},
'S': {0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E},
'T': {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04},
'U': {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E},
'V': {0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04},
'W': {0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11},
'X': {0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11},
'Y': {0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04},
'Z': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F},
'2': {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F},
'3': {0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E},
'4': {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02},
'5': {0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E},
'6': {0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E},
'7': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08},
'8': {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E},
'9': {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C},
}
)
func captchaAnswerHash(answer string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToUpper(answer))))
return hex.EncodeToString(sum[:])
}
func randBounded(maxExclusive int) (int, error) {
if maxExclusive <= 0 {
return 0, fmt.Errorf("invalid bound")
}
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, err
}
return int(binary.BigEndian.Uint64(buf[:]) % uint64(maxExclusive)), nil
}
func randomCaptchaText() (string, error) {
n := captchaMinLen
extra, err := randBounded(captchaMaxLen - captchaMinLen + 1)
if err != nil {
return "", err
}
n += extra
out := make([]byte, n)
for i := range out {
idx, err := randBounded(len(captchaCharset))
if err != nil {
return "", err
}
out[i] = captchaCharset[idx]
}
return string(out), nil
}
func renderCaptchaPNG(text string) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, captchaImgW, captchaImgH))
bg := color.RGBA{R: 248, G: 250, B: 252, A: 255}
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
// Light speckled noise.
for i := 0; i < 180; i++ {
x, err := randBounded(captchaImgW)
if err != nil {
return nil, err
}
y, err := randBounded(captchaImgH)
if err != nil {
return nil, err
}
shade, err := randBounded(80)
if err != nil {
return nil, err
}
c := uint8(160 + shade)
img.Set(x, y, color.RGBA{R: c, G: c, B: c, A: 255})
}
// A few interference lines.
ink := color.RGBA{R: 30, G: 41, B: 59, A: 255}
for i := 0; i < 3; i++ {
x0, err := randBounded(captchaImgW)
if err != nil {
return nil, err
}
y0, err := randBounded(captchaImgH)
if err != nil {
return nil, err
}
x1, err := randBounded(captchaImgW)
if err != nil {
return nil, err
}
y1, err := randBounded(captchaImgH)
if err != nil {
return nil, err
}
drawLine(img, x0, y0, x1, y1, color.RGBA{R: 148, G: 163, B: 184, A: 180})
}
scale := 3
glyphW := 5 * scale
gap := 4
totalW := len(text)*glyphW + (len(text)-1)*gap
startX := (captchaImgW - totalW) / 2
if startX < 4 {
startX = 4
}
baseY := (captchaImgH - 7*scale) / 2
for i := 0; i < len(text); i++ {
ch := text[i]
glyph, ok := captchaGlyphs[ch]
if !ok {
continue
}
jitter, err := randBounded(5)
if err != nil {
return nil, err
}
ox := startX + i*(glyphW+gap)
oy := baseY + jitter - 2
drawGlyph(img, ox, oy, glyph, scale, ink)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func drawGlyph(img *image.RGBA, ox, oy int, glyph [7]byte, scale int, c color.RGBA) {
for row := 0; row < 7; row++ {
bits := glyph[row]
for col := 0; col < 5; col++ {
if bits&(1<<uint(4-col)) == 0 {
continue
}
for dy := 0; dy < scale; dy++ {
for dx := 0; dx < scale; dx++ {
x := ox + col*scale + dx
y := oy + row*scale + dy
if x >= 0 && y >= 0 && x < img.Bounds().Dx() && y < img.Bounds().Dy() {
img.Set(x, y, c)
}
}
}
}
}
}
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
dx := x1 - x0
if dx < 0 {
dx = -dx
}
dy := y1 - y0
if dy < 0 {
dy = -dy
}
sx, sy := 1, 1
if x0 > x1 {
sx = -1
}
if y0 > y1 {
sy = -1
}
err := dx - dy
for {
if x0 >= 0 && y0 >= 0 && x0 < img.Bounds().Dx() && y0 < img.Bounds().Dy() {
img.Set(x0, y0, c)
}
if x0 == x1 && y0 == y1 {
break
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x0 += sx
}
if e2 < dx {
err += dx
y0 += sy
}
}
}
func captchaImageBase64(text string) (string, error) {
pngBytes, err := renderCaptchaPNG(text)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(pngBytes), nil
}
func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
if s.redis == nil {
if s.cfg.DevMode {
img, err := captchaImageBase64("A2B3")
if err != nil {
writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد")
return
}
resp := map[string]any{
"challengeId": "dev",
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
if s.cfg.CaptchaExposeAnswer {
resp["answer"] = "A2B3"
}
writeJSON(w, http.StatusOK, resp)
return
}
writeErr(w, http.StatusServiceUnavailable, "سرویس کد امنیتی در دسترس نیست")
return
}
text, err := randomCaptchaText()
if err != nil {
writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد")
return
}
img, err := captchaImageBase64(text)
if err != nil {
writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد")
return
}
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد")
return
}
id := hex.EncodeToString(raw)
key := captchaKeyPrefix + id
if err := s.redis.Set(r.Context(), key, captchaAnswerHash(text), captchaTTL).Err(); err != nil {
writeErr(w, http.StatusServiceUnavailable, "سرویس کد امنیتی در دسترس نیست")
return
}
resp := map[string]any{
"challengeId": id,
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
// Never expose plaintext answers in production. Local tests may opt in via
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
if s.cfg.DevMode && s.cfg.CaptchaExposeAnswer {
resp["answer"] = text
}
writeJSON(w, http.StatusOK, resp)
}
// consumeCaptcha verifies and one-time-deletes a challenge. On any failure the
// challenge is consumed (or already gone) so the client must fetch a new one.
func (s *Server) consumeCaptcha(ctx context.Context, challengeID, answer string) error {
challengeID = strings.TrimSpace(challengeID)
answer = strings.TrimSpace(answer)
if challengeID == "" || answer == "" {
return errCaptchaRequired
}
if s.redis == nil {
if s.cfg.DevMode && challengeID == "dev" && strings.EqualFold(answer, "A2B3") {
return nil
}
return errCaptchaUnavailable
}
key := captchaKeyPrefix + challengeID
stored, err := s.redis.GetDel(ctx, key).Result()
if err == redis.Nil {
return errCaptchaInvalid
}
if err != nil {
return errCaptchaUnavailable
}
if stored != captchaAnswerHash(answer) {
return errCaptchaInvalid
}
return nil
}
func captchaErrorMessage(err error) (int, string) {
switch {
case errors.Is(err, errCaptchaRequired):
return http.StatusBadRequest, "کد امنیتی الزامی است"
case errors.Is(err, errCaptchaInvalid):
return http.StatusBadRequest, "پاسخ کد امنیتی نادرست یا منقضی است"
case errors.Is(err, errCaptchaUnavailable):
return http.StatusServiceUnavailable, "سرویس کد امنیتی در دسترس نیست"
default:
return http.StatusBadRequest, "پاسخ کد امنیتی نادرست یا منقضی است"
}
}