Files

852 lines
22 KiB
Go
Raw Permalink Normal View History

//
// 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.
//
package handler
import (
"fmt"
"net/http"
"ragflow/internal/common"
"ragflow/internal/engine/redis"
"ragflow/internal/server"
"ragflow/internal/server/local"
"ragflow/internal/utility"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"ragflow/internal/service"
)
// UserHandler user handler
type UserHandler struct {
userService *service.UserService
}
// NewUserHandler create user handler
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{
userService: userService,
}
}
// Register user registration
// @Summary User Registration
// @Description Create new user
// @Tags users
// @Accept json
// @Produce json
// @Param request body service.RegisterRequest true "registration info"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/users [post]
func (h *UserHandler) Register(c *gin.Context) {
var req service.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeBadRequest,
"message": err.Error(),
"data": false,
})
return
}
user, code, err := h.userService.Register(&req)
if err != nil {
var data interface{} = false
if code == common.CodeExceptionError {
data = nil
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": data,
})
return
}
secretKey, err := server.GetSecretKey(redis.Get())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": fmt.Sprintf("Failed to get secret key: %s", err.Error()),
"data": false,
})
return
}
authToken, err := utility.DumpAccessToken(*user.AccessToken, secretKey)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": "Failed to generate auth token",
"data": false,
})
return
}
c.Header("Authorization", authToken)
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "*")
c.Header("Access-Control-Allow-Headers", "*")
c.Header("Access-Control-Expose-Headers", "Authorization")
profile := h.userService.GetUserProfile(user)
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": fmt.Sprintf("%s, welcome aboard!", req.Nickname),
"data": profile,
})
}
// Login user login
// @Summary User Login
// @Description User login verification
// @Tags users
// @Accept json
// @Produce json
// @Param request body service.LoginRequest true "login info"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/users/login [post]
func (h *UserHandler) Login(c *gin.Context) {
var req service.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeBadRequest,
"message": err.Error(),
"data": false,
})
return
}
user, code, err := h.userService.Login(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
// Sign the access_token using itsdangerous (compatible with Python)
secretKey, err := server.GetSecretKey(redis.Get())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": fmt.Sprintf("Failed to get secret key: %s", err.Error()),
"data": false,
})
return
}
authToken, err := utility.DumpAccessToken(*user.AccessToken, secretKey)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": "Failed to generate auth token",
"data": false,
})
return
}
// Set Authorization header with signed token
c.Header("Authorization", authToken)
// Set CORS headers
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "*")
c.Header("Access-Control-Allow-Headers", "*")
c.Header("Access-Control-Expose-Headers", "Authorization")
profile := h.userService.GetUserProfile(user)
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "Welcome back!",
"data": profile,
})
}
// LoginByEmail user login by email
// @Summary User Login by Email
// @Description User login verification using email
// @Tags users
// @Accept json
// @Produce json
// @Param request body service.EmailLoginRequest true "login info with email"
// @Success 200 {object} map[string]interface{}
// @Router /v1/user/login [post]
func (h *UserHandler) LoginByEmail(c *gin.Context) {
var req service.EmailLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeBadRequest,
"message": err.Error(),
"data": false,
})
return
}
if !local.IsAdminAvailable() {
license := local.GetAdminStatus()
c.JSON(http.StatusOK, gin.H{
"code": common.CodeAuthenticationError,
"message": license.Reason,
"data": "No",
})
return
}
user, code, err := h.userService.LoginByEmail(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
secretKey, err := server.GetSecretKey(redis.Get())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": fmt.Sprintf("Failed to get secret key: %s", err.Error()),
"data": false,
})
return
}
authToken, err := utility.DumpAccessToken(*user.AccessToken, secretKey)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": "Failed to generate auth token",
"data": false,
})
return
}
c.Header("Authorization", authToken)
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "*")
c.Header("Access-Control-Allow-Headers", "*")
c.Header("Access-Control-Expose-Headers", "Authorization")
profile := h.userService.GetUserProfile(user)
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "Welcome back!",
"data": profile,
})
}
// GetUserByID get user by ID
// @Summary Get User Info
// @Description Get user details by ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "user ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/users/{id} [get]
func (h *UserHandler) GetUserByID(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeBadRequest,
"message": "invalid user id",
"data": false,
})
return
}
user, code, err := h.userService.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "success",
"data": user,
})
}
// ListUsers user list
// @Summary User List
// @Description Get paginated user list
// @Tags users
// @Accept json
// @Produce json
// @Param page query int false "page number" default(1)
// @Param page_size query int false "items per page" default(10)
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/users [get]
func (h *UserHandler) ListUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
users, total, code, err := h.userService.ListUsers(page, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "success",
"data": gin.H{
"items": users,
"total": total,
"page": page,
"page_size": pageSize,
},
})
}
// Logout user logout
// @Summary User Logout
// @Description Logout user and invalidate access token
// @Tags users
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} map[string]interface{}
// @Router /v1/user/logout [post]
func (h *UserHandler) Logout(c *gin.Context) {
// Same as AuthMiddleware@auth.go
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "Missing Authorization header",
})
c.Abort()
return
}
// Get user by access token
user, code, err := h.userService.GetUserByToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": code,
"message": "Invalid access token",
})
c.Abort()
return
}
// Logout user
code, err = h.userService.Logout(user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": true,
"message": "success",
})
}
// Info get user profile information
// @Summary Get User Profile
// @Description Get current user's profile information
// @Tags users
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} map[string]interface{}
// @Router /v1/user/info [get]
func (h *UserHandler) Info(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
// Get user profile
profile := h.userService.GetUserProfile(user)
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "success",
"data": profile,
})
}
// Setting update user settings
// @Summary Update User Settings
// @Description Update current user's settings
// @Tags users
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body service.UpdateSettingsRequest true "user settings"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/users/me [patch]
func (h *UserHandler) Setting(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
// Parse request
var req service.UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeBadRequest,
"message": err.Error(),
"data": false,
})
return
}
// Update user settings
code, err := h.userService.UpdateUserSettings(user, &req)
if err != nil {
if code == common.CodeExceptionError {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "success",
"data": true,
})
}
// ChangePassword change user password
// @Summary Change User Password
// @Description Change current user's password
// @Tags users
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body service.ChangePasswordRequest true "password change info"
// @Success 200 {object} map[string]interface{}
// @Router /v1/user/setting/password [post]
func (h *UserHandler) ChangePassword(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
// Parse request
var req service.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeBadRequest,
"message": err.Error(),
"data": false,
})
return
}
// Change password
code, err := h.userService.ChangePassword(user, &req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "password changed successfully",
"data": true,
})
}
// GetLoginChannels get all supported authentication channels
// @Summary Get Login Channels
// @Description Get all supported OAuth authentication channels
// @Tags users
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /v1/user/login/channels [get]
func (h *UserHandler) GetLoginChannels(c *gin.Context) {
channels, code, err := h.userService.GetLoginChannels()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": "Load channels failure, error: " + err.Error(),
"data": []interface{}{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "success",
"data": channels,
})
}
// SetTenantInfo update tenant information
// @Summary Set Tenant Info
// @Description Update tenant model configuration
// @Tags users
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body service.SetTenantInfoRequest true "tenant info"
// @Success 200 {object} map[string]interface{}
// @Router /v1/user/set_tenant_info [post]
func (h *UserHandler) SetTenantInfo(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
requiredKeys := []string{"tenant_id", "asr_id", "embd_id", "img2txt_id", "llm_id"}
missingArgumentMessage := "required argument are missing: tenant_id,asr_id,embd_id,img2txt_id,llm_id; "
var payload map[string]interface{}
if err := c.ShouldBindBodyWith(&payload, binding.JSON); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeArgumentError,
"message": missingArgumentMessage,
"data": nil,
})
return
}
missing := make([]string, 0, len(requiredKeys))
for _, key := range requiredKeys {
if _, ok := payload[key]; !ok {
missing = append(missing, key)
}
}
if len(missing) > 0 {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeArgumentError,
"message": fmt.Sprintf("required argument are missing: %s; ", joinStrings(missing)),
"data": nil,
})
return
}
req := service.SetTenantInfoRequest{Raw: payload}
if value, ok := payload["tenant_id"].(string); ok {
req.TenantID = &value
}
if value, ok := payload["asr_id"].(string); ok {
req.ASRID = &value
}
if value, ok := payload["embd_id"].(string); ok {
req.EmbdID = &value
}
if value, ok := payload["img2txt_id"].(string); ok {
req.Img2TxtID = &value
}
if value, ok := payload["llm_id"].(string); ok {
req.LLMID = &value
}
if value, ok := payload["rerank_id"].(string); ok {
req.RerankID = &value
}
if value, ok := payload["tts_id"].(string); ok {
req.TTSID = &value
}
code, err := h.userService.SetTenantInfo(user.ID, &req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "success",
"data": true,
})
}
func joinStrings(values []string) string {
if len(values) == 0 {
return ""
}
result := values[0]
for i := 1; i < len(values); i++ {
result += "," + values[i]
}
return result
}
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 06:27:56 -07:00
// ---- Forgot-password flow (fixes #15282) -----------------------------
//
// Mirrors api/apps/restful_apis/user_api.py /auth/password/... endpoints.
//
// Contract divergence from Python: the Python endpoint returns a
// rendered image (Content-Type: image/JPEG) from the python-captcha
// library and stores the captcha under captcha:<email>. This Go port
// returns a server-issued captcha_id plus a PNG captcha image (as a
// data URL the FE drops straight into <img src>), and stores
// captcha:<captcha_id>. The plaintext text only ever appears as
// raster pixels — the OTP step reuses the captcha_id to look the
// expected text up server-side.
//
// The PNG is rendered using stdlib `image/png` + a hand-rolled 5x7
// bitmap font in internal/utility/captcha_png.go, because no Go
// captcha library is vendored in go.mod (no network during build).
// PR #15290 review (Hz-186) explicitly asked for a raster after the
// earlier SVG implementation: the SVG embedded the answer in <text>
// nodes, so a scripted client could base64-decode the response and
// grep the captcha directly. PNG closes that attack — the response
// bytes never reference the original text.
type forgotCaptchaRequest struct {
Email string `form:"email" json:"email"`
}
// ForgotCaptcha POST /api/v1/auth/password/forgot/captcha
// @Summary Issue forgot-password captcha
// @Description Generates a captcha for the email and stores it in Redis
// for 60 seconds keyed by a server-issued captcha_id. Returns the id
// and a PNG image (data URL) the FE renders inside <img src>. The
// plaintext code never appears in the response — only as raster
// pixels — so a scripted client can't regex it out (fixes the
// SVG-text leak from the previous iteration, per PR #15290 review).
// @Tags auth
// @Accept json
// @Produce json
// @Param email query string false "user email (also accepted in JSON body)"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/auth/password/forgot/captcha [post]
func (h *UserHandler) ForgotCaptcha(c *gin.Context) {
var req forgotCaptchaRequest
// Python reads from request.args (query string), accept both for parity.
if v := c.Query("email"); v != "" {
req.Email = v
} else {
_ = c.ShouldBindJSON(&req)
}
captchaID, captchaImage, errCode, err := h.userService.ForgotIssueCaptcha(req.Email)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": errCode,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "captcha issued",
"data": gin.H{
"captcha_id": captchaID,
"captcha_image": captchaImage,
},
})
}
type forgotSendOTPRequest struct {
Email string `json:"email"`
CaptchaID string `json:"captcha_id"`
Captcha string `json:"captcha"`
}
// ForgotSendOTP POST /api/v1/auth/password/forgot/otp
// @Summary Send forgot-password OTP
// @Description Validates the captcha (looked up by captcha_id), then
// mints a one-time code, stores a salted hash in Redis (5 min TTL,
// attempt cap, resend cooldown), and emails the OTP to the user.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body forgotSendOTPRequest true "email + captcha_id + captcha"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/auth/password/forgot/otp [post]
func (h *UserHandler) ForgotSendOTP(c *gin.Context) {
var req forgotSendOTPRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeArgumentError,
"message": err.Error(),
"data": false,
})
return
}
errCode, err := h.userService.ForgotSendOTP(req.Email, req.CaptchaID, req.Captcha)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": errCode,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "verification passed, email sent",
"data": true,
})
}
type forgotVerifyOTPRequest struct {
Email string `json:"email"`
OTP string `json:"otp"`
}
// ForgotVerifyOTP POST /api/v1/auth/password/forgot/otp/verify
// @Summary Verify forgot-password OTP
// @Description Consumes the OTP if it matches, sets a short-lived
// verified flag the reset endpoint will gate on. Wrong-OTP attempts
// are counted and a 30-minute lockout kicks in at the limit.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body forgotVerifyOTPRequest true "email + otp"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/auth/password/forgot/otp/verify [post]
func (h *UserHandler) ForgotVerifyOTP(c *gin.Context) {
var req forgotVerifyOTPRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeArgumentError,
"message": err.Error(),
"data": false,
})
return
}
errCode, err := h.userService.ForgotVerifyOTP(req.Email, req.OTP)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": errCode,
"message": err.Error(),
"data": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "otp verified",
"data": true,
})
}
// ForgotResetPassword POST /api/v1/auth/password/reset
// @Summary Reset password after OTP verification
// @Description Requires a successful prior verify call (verified flag
// set in Redis). Updates the password hash and rotates the access
// token so the response can auto-login the user.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body service.ForgotResetPasswordRequest true "email + new password"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/auth/password/reset [post]
func (h *UserHandler) ForgotResetPassword(c *gin.Context) {
var req service.ForgotResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeArgumentError,
"message": err.Error(),
"data": false,
})
return
}
user, code, err := h.userService.ForgotResetPassword(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": code,
"message": err.Error(),
"data": false,
})
return
}
secretKey, err := server.GetSecretKey(redis.Get())
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 06:27:56 -07:00
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": fmt.Sprintf("Failed to get secret key: %s", err.Error()),
"data": false,
})
return
}
authToken, err := utility.DumpAccessToken(*user.AccessToken, secretKey)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeServerError,
"message": "Failed to generate auth token",
"data": false,
})
return
}
c.Header("Authorization", authToken)
c.Header("Access-Control-Expose-Headers", "Authorization")
// GetUserProfile includes the password hash and the live access_token,
// which must never appear in the reset response body (the token is
// already in the Authorization header). Mirror the Python contract
// `user.to_safe_dict(for_self=True)` by stripping those fields before
// writing. PR #15290 review.
profile := h.userService.GetUserProfile(user)
delete(profile, "password")
delete(profile, "access_token")
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"message": "Password reset successful. Logged in.",
"data": profile,
})
}