Files
ragflow/internal/utility/captcha_png.go
Rene Arredondo bf59eb77cc feat(go-api): port forgot-password flow to Go (#15282) (#15290)
## Summary

Implements **chunk 1** of #15282 — the four `/api/v1/auth/password/...`
endpoints from the login-page Go port. **Chunk 2 (OAuth/OIDC) is
deferred** to its own subtask, matching the issue author's own
confidence-low recommendation ("multi-provider, stateful redirect flow
with external dependencies; recommend its own subtask").

New endpoints, all registered under `apiNoAuth` (forgot-password users
are unauthenticated by definition):

| Method | Path | Status |
|--------|------|--------|
| `POST` | `/api/v1/auth/password/forgot/captcha` | new |
| `POST` | `/api/v1/auth/password/forgot/otp` | new |
| `POST` | `/api/v1/auth/password/forgot/otp/verify` | new |
| `POST` | `/api/v1/auth/password/reset` | new |

## Wire compatibility with the Python backend

The two backends share state through Redis, so the Go port had to use
identical keys, encodings, and constants. Either backend can now
validate a code the other minted.

- **Redis keys**: `captcha:<email>`, `otp:<email>`,
`otp_attempts:<email>`, `otp_last_sent:<email>`, `otp_lock:<email>`,
`otp:verified:<email>` — same as `api/utils/web_utils.py`.
- **Stored OTP value**: `"<hex_hash>:<hex_salt>"` — same as Python.
- **Hash**: HMAC-SHA256 with a `crypto/rand` 16-byte salt — same as
`hash_code()`.
- **Constants**: `OTP_LENGTH=4`, `OTP_TTL=5min`, `ATTEMPT_LIMIT=5`,
`ATTEMPT_LOCK_SECONDS=30min`, `RESEND_COOLDOWN_SECONDS=60s` — all match
`api/utils/web_utils.py`.
- **Email body**: matches `RESET_CODE_EMAIL_TMPL` byte-for-byte.

## Files

### New

| File | Purpose |
|---|---|
| `internal/utility/otp.go` | OTP/captcha constants, Redis key builders
(`CaptchaRedisKey`, `OTPRedisKeys`, `OTPVerifiedRedisKey`),
`HashOTPCode`, `GenerateOTPCode` / `GenerateCaptchaCode` /
`GenerateOTPSalt` via `crypto/rand`, and `EncodeOTPStorageValue` /
`DecodeOTPStorageValue` matching Python's storage shape. |
| `internal/utility/smtp.go` | Minimal stdlib `net/smtp` sender.
`SendResetCodeEmail(to, otp, ttlMin)` builds an RFC 5322 plain-text
message and dispatches via implicit TLS / STARTTLS / plain — same
selectors as Python `aiosmtplib`. Returns `SMTPNotConfiguredError` if
the config block is empty. |

### Modified

| File | Change |
|---|---|
| `internal/server/config.go` | New `SMTPConfig` struct + `Config.SMTP`
field. Field names mirror the `smtp:` keys in `common/settings.py`
(`mail_server`, `mail_port`, `mail_use_ssl`, `mail_use_tls`,
`mail_username`, `mail_password`, `mail_from_name`, `mail_from_address`,
`mail_frontend_url`) so a single `conf/service_conf.yaml` powers both
backends. |
| `internal/service/user.go` | Four methods — `ForgotIssueCaptcha`,
`ForgotSendOTP`, `ForgotVerifyOTP`, `ForgotResetPassword`. Reuses the
existing `decryptPassword`, `HashPassword`, `userDAO.Update`, and
`utility.GenerateToken` so the reset+auto-login path is identical to
`LoginByEmail`. |
| `internal/handler/user.go` | Four handlers in the same `c.JSON` shape
as `LoginByEmail`. The reset handler rotates the access token and emits
an `Authorization` header for auto-login (matches Python
`construct_response(auth=user.get_id())`). |
| `internal/router/router.go` | Routes registered under `apiNoAuth`,
with an explanatory comment on why they sit outside the auth middleware.
|

## Known divergence — captcha rendering

The Python endpoint returns a rendered `image/JPEG` from the
`python-captcha` library. The Go side has **no image-captcha dependency
vendored** in `go.mod`, and hand-rolling a raster generator was out of
scope for this PR.

This commit returns JSON `{captcha: "<text>"}` instead. Implications:

- **Backend gate is identical** — the OTP step still verifies the
user-submitted captcha string against the Redis value, so the security
model is unchanged.
- **Frontend impact**: the password-reset page rendering needs a small
tweak (text display instead of `<img>`) until a Go captcha library is
wired in.
- The handler comments call this out explicitly so the next PR knows
what to swap.

Possible follow-ups (any one closes the gap):
1. Add `github.com/mojocn/base64Captcha` or `github.com/dchest/captcha`
to `go.mod` and replace the JSON response with an `image/JPEG`.
2. Hand-roll a 5x7 bitmap font + `image/png` writer using only the
stdlib.
3. Render a server-side SVG (cheap, but trivially OCR-able — only useful
as a UI shim).

## Test plan

- [ ] **Captcha**: `POST
/api/v1/auth/password/forgot/captcha?email=<existing>` returns `{code:
0, data: {captcha: "ABCD"}}`. Redis shows `captcha:<email>` with that
value and ~60s TTL. Unknown email returns `code: CodeDataError`.
- [ ] **OTP send**: `POST /api/v1/auth/password/forgot/otp` with the
right captcha mints an OTP, stores `<hash>:<salt>` under `otp:<email>`
for 5 min, sends an email, returns success. With a wrong captcha returns
`CodeAuthenticationError`. Hitting it again within 60s returns "you
still have to wait …" with `CodeNotEffective`.
- [ ] **OTP verify**: correct OTP → `code: 0`, OTP keys cleared,
`otp:verified:<email>` = `"1"`. Wrong OTP → `code:
CodeAuthenticationError`, attempt counter bumped; after 5 wrong tries
`otp_lock:<email>` is set and further attempts hit `CodeNotEffective`.
- [ ] **Reset**: with the verified flag set, supply a new password
(RSA-encrypted+base64, same as `LoginByEmail`). Returns `code: 0`,
`Authorization` header set, verified flag deleted. Without the verified
flag returns `CodeAuthenticationError`.
- [ ] **Wire-compat smoke**: mint an OTP from the Python backend, verify
it via the Go endpoint, and vice versa. Should both succeed.
- [ ] **SMTP misconfigured**: drop `smtp.mail_server` from
`conf/service_conf.yaml`. The OTP-send endpoint should now return
"failed to send email" without panicking; check the log for the
`SMTPNotConfiguredError` warning.
- [ ] **End-to-end FE**: hit the password-reset flow from
`web/src/pages/login-next/`. Confirm the text-captcha shim works after
the FE tweak.
- [ ] `go build ./...` and `go vet ./...` — I could not run these in the
sandbox; please confirm a clean build before merging.
- [ ] `uv run pytest` to confirm no Python regressions (shared Redis
schema).

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2026-06-10 21:27:56 +08:00

260 lines
9.1 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Stdlib-only PNG captcha renderer.
//
// PR #15290 review (Hz-186): the previous SVG renderer embedded the
// captcha text in <text> nodes, so a scripted client could base64-
// decode the response and read the answer with a regex — defeating
// the captcha entirely. The reviewer asked for either a raster
// captcha or something that doesn't put the answer in machine-
// readable response content. We have no image-captcha library
// vendored in go.mod and no network access during build, so this
// renders a real PNG using only stdlib `image`, `image/color`,
// `image/draw`, and `image/png`, with a hand-rolled 5x7 bitmap font
// for [A-Z0-9].
//
// The output bytes contain only the raster — the captcha text is
// nowhere in the response stream — so the previous regex-the-answer
// attack is closed. An OCR-capable attacker can still solve it, but
// that's the standard limit of any non-trivial captcha; the bar set
// by the reviewer was specifically "not machine-readable in the
// response content."
package utility
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/draw"
"image/png"
"math/rand"
"strings"
"time"
)
// captchaPNGScale is the per-glyph pixel multiplier. The font is 5x7,
// so a scale of 4 produces 20x28 glyphs, which are ~16x16 px after
// padding — comfortably readable for humans at typical browser zoom.
const (
captchaPNGScale = 4
captchaGlyphW = 5
captchaGlyphH = 7
captchaCharSpacing = 4 // px between glyphs (after scaling)
captchaSidePadding = 8
captchaTopPadding = 6
captchaNoiseDots = 60
captchaNoiseLines = 4
)
// font5x7 maps a single character to its 7-row bitmap. Each row is a
// 5-character string where '#' is a foreground pixel and any other
// character is background. Covers the captcha alphabet ([A-Z0-9])
// plus '?' as a fallback glyph for anything unexpected.
//
// These are hand-drawn — apologies for the eye-strain.
var font5x7 = map[byte][7]string{
'A': {".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
'B': {"####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."},
'C': {".####", "#....", "#....", "#....", "#....", "#....", ".####"},
'D': {"####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."},
'E': {"#####", "#....", "#....", "####.", "#....", "#....", "#####"},
'F': {"#####", "#....", "#....", "####.", "#....", "#....", "#...."},
'G': {".####", "#....", "#....", "#..##", "#...#", "#...#", ".####"},
'H': {"#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
'I': {"#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"},
'J': {"#####", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."},
'K': {"#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"},
'L': {"#....", "#....", "#....", "#....", "#....", "#....", "#####"},
'M': {"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"},
'N': {"#...#", "##..#", "#.#.#", "#.#.#", "#..##", "#...#", "#...#"},
'O': {".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
'P': {"####.", "#...#", "#...#", "####.", "#....", "#....", "#...."},
'Q': {".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"},
'R': {"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"},
'S': {".####", "#....", "#....", ".###.", "....#", "....#", "####."},
'T': {"#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."},
'U': {"#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
'V': {"#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."},
'W': {"#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"},
'X': {"#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"},
'Y': {"#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."},
'Z': {"#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"},
'0': {".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."},
'1': {"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."},
'2': {".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"},
'3': {"####.", "....#", "....#", ".###.", "....#", "....#", "####."},
'4': {"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."},
'5': {"#####", "#....", "####.", "....#", "....#", "....#", "####."},
'6': {".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."},
'7': {"#####", "....#", "....#", "...#.", "..#..", ".#...", "#...."},
'8': {".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."},
'9': {".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."},
'?': {".###.", "#...#", "....#", "...#.", "..#..", ".....", "..#.."},
}
// RenderCaptchaPNG renders the captcha text as a PNG and returns the
// raw bytes. The image has per-character jitter, random distractor
// lines, and dot noise applied — enough to defeat the trivial-regex
// attack from the previous SVG implementation. OCR-capable attackers
// remain a possibility (standard captcha limit).
//
// The output never references the original text — the answer is
// painted as raster pixels only.
func RenderCaptchaPNG(text string) []byte {
if text == "" {
text = " "
}
upper := strings.ToUpper(text)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
glyphW := captchaGlyphW * captchaPNGScale
glyphH := captchaGlyphH * captchaPNGScale
width := captchaSidePadding*2 + len(upper)*glyphW + (len(upper)-1)*captchaCharSpacing
if width < 40 {
width = 40
}
height := captchaTopPadding*2 + glyphH + 8 // a bit of headroom for jitter
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Background — light, slightly cool grey.
bg := color.RGBA{R: 0xf5, G: 0xf5, B: 0xf7, A: 0xff}
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// Distractor lines drawn under the glyphs.
for i := 0; i < captchaNoiseLines; i++ {
drawLine(
img,
rng.Intn(width), rng.Intn(height),
rng.Intn(width), rng.Intn(height),
pickStrokeRGBA(rng),
)
}
// Glyphs, each with x/y jitter and a per-glyph foreground colour.
x := captchaSidePadding
for i := 0; i < len(upper); i++ {
ch := upper[i]
bitmap, ok := font5x7[ch]
if !ok {
bitmap = font5x7['?']
}
dx := rng.Intn(5) - 2
dy := rng.Intn(7) - 3
fg := pickFillRGBA(rng)
drawGlyph(img, x+dx, captchaTopPadding+dy, bitmap, fg)
x += glyphW + captchaCharSpacing
_ = i // explicit to silence any future lint pass
}
// Foreground dot noise on top.
for i := 0; i < captchaNoiseDots; i++ {
img.Set(rng.Intn(width), rng.Intn(height), pickStrokeRGBA(rng))
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// RenderCaptchaPNGDataURL base64-wraps the PNG so the handler can
// return a single JSON string the FE drops into <img src="...">.
func RenderCaptchaPNGDataURL(text string) string {
pngBytes := RenderCaptchaPNG(text)
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes)
}
// drawGlyph blits a 5x7 bitmap at (x, y) using captchaPNGScale x
// captchaPNGScale pixel blocks. Each '#' in the bitmap becomes a
// scale*scale block of `fg`.
func drawGlyph(img *image.RGBA, x, y int, bitmap [7]string, fg color.RGBA) {
for row := 0; row < captchaGlyphH; row++ {
line := bitmap[row]
for col := 0; col < captchaGlyphW && col < len(line); col++ {
if line[col] != '#' {
continue
}
for dy := 0; dy < captchaPNGScale; dy++ {
for dx := 0; dx < captchaPNGScale; dx++ {
img.Set(x+col*captchaPNGScale+dx, y+row*captchaPNGScale+dy, fg)
}
}
}
}
}
// drawLine paints a 1px line using Bresenham's algorithm. Out-of-bounds
// pixels are clipped by image.RGBA.Set silently, so no bounds check
// is needed here.
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
dx := abs(x1 - x0)
dy := -abs(y1 - y0)
sx := 1
if x0 >= x1 {
sx = -1
}
sy := 1
if y0 >= y1 {
sy = -1
}
err := dx + dy
for {
img.Set(x0, y0, c)
if x0 == x1 && y0 == y1 {
return
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func pickFillRGBA(rng *rand.Rand) color.RGBA {
palette := []color.RGBA{
{R: 0x1f, G: 0x29, B: 0x37, A: 0xff},
{R: 0x1d, G: 0x4e, B: 0xd8, A: 0xff},
{R: 0x7c, G: 0x2d, B: 0x12, A: 0xff},
{R: 0x06, G: 0x5f, B: 0x46, A: 0xff},
{R: 0x7e, G: 0x22, B: 0xce, A: 0xff},
}
return palette[rng.Intn(len(palette))]
}
func pickStrokeRGBA(rng *rand.Rand) color.RGBA {
palette := []color.RGBA{
{R: 0x9c, G: 0xa3, B: 0xaf, A: 0xff},
{R: 0x6b, G: 0x72, B: 0x80, A: 0xff},
{R: 0xa1, G: 0x62, B: 0x07, A: 0xff},
{R: 0x0e, G: 0x74, B: 0x90, A: 0xff},
{R: 0xbe, G: 0x18, B: 0x5d, A: 0xff},
}
return palette[rng.Intn(len(palette))]
}