mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 22:25:41 +08:00
## 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)
260 lines
9.1 KiB
Go
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))]
|
|
}
|