351 lines
9.1 KiB
Go
351 lines
9.1 KiB
Go
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 = 5 * 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,
|
|||
|
|
}
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
// 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, "پاسخ کد امنیتی نادرست یا منقضی است"
|
|||
|
|
}
|
|||
|
|
}
|