Files
story/internal/onboarding/captcha_policy.go
T

158 lines
4.8 KiB
Go
Raw Normal View History

package onboarding
import (
"context"
"data-terminal-cloud/internal/audit"
"encoding/json"
"net"
"net/http"
"net/netip"
"strings"
)
type captchaRequestKey struct{}
type captchaRequestInfo struct{ client, ip string }
func captchaRequest(ctx context.Context) captchaRequestInfo {
if v, ok := ctx.Value(captchaRequestKey{}).(captchaRequestInfo); ok {
return v
}
return captchaRequestInfo{client: "saas"}
}
// Walk the proxy chain from the trusted peer, never trust the leftmost value
// merely because a client supplied X-Forwarded-For.
func (s *Service) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip, err := netip.ParseAddr(host)
if err != nil {
return ""
}
trusted := func(ip netip.Addr) bool {
for _, cidr := range s.options.TrustedProxyCIDRs {
prefix, e := netip.ParsePrefix(cidr)
if e == nil && prefix.Contains(ip) {
return true
}
}
return false
}
if trusted(ip) {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0 && trusted(ip); i-- {
next, e := netip.ParseAddr(strings.TrimSpace(parts[i]))
if e != nil {
break
}
ip = next
}
}
return ip.String()
}
type captchaPolicy struct {
SaaS string `json:"saas_mode"`
Mini string `json:"mini_mode"`
Version int64 `json:"version"`
}
func (p captchaPolicy) mode(client string) string {
if client == "mini_program" {
return p.Mini
}
return p.SaaS
}
func (s *Service) readCaptchaPolicy(ctx context.Context, q queryer) (captchaPolicy, error) {
var p captchaPolicy
err := q.QueryRow(ctx, `SELECT saas_mode,mini_mode,version FROM login_captcha_policy WHERE singleton`).Scan(&p.SaaS, &p.Mini, &p.Version)
return p, err
}
func (s *Service) captchaAppID(client string) string {
if s.options.Captcha == nil {
return ""
}
return s.options.Captcha.AppID(client)
}
func (s *Service) captchaPolicyResult(p captchaPolicy) map[string]any {
return map[string]any{"saas_mode": p.SaaS, "mini_mode": p.Mini, "version": p.Version, "saas_configured": s.captchaAppID("saas") != "", "mini_configured": s.captchaAppID("mini_program") != "", "saas_disabled": s.options.SaaSCaptchaDisabled}
}
func (s *Service) captchaPolicyHTTP(r *http.Request, requestID string) (any, error) {
if r.URL.RawQuery != "" {
return nil, errAccess
}
p, err := s.operatorView().browserPrincipal(r, r.Method != http.MethodGet)
if err != nil {
return nil, err
}
ctx := r.Context()
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
a, err := operatorAccess(ctx, tx, p, r.Header.Get("X-Tenant-ID"), false)
if err != nil {
return nil, err
}
// This affects all login entry points, so ordinary operators cannot change it.
if a.Role != "ops_admin" {
return nil, errAccess
}
policy, err := s.readCaptchaPolicy(ctx, tx)
if err != nil {
return nil, err
}
if r.Method == http.MethodGet {
return s.captchaPolicyResult(policy), nil
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
var in captchaPolicy
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
if !oneOf(in.SaaS, "image", "slide", "tencent") || !oneOf(in.Mini, "image", "tencent") || in.Version < 1 {
return nil, fail(400, "VALIDATION_FAILED", "请选择有效的验证码方式")
}
if (in.SaaS == "tencent" && s.captchaAppID("saas") == "") || (in.Mini == "tencent" && s.captchaAppID("mini_program") == "") {
return nil, fail(409, "CAPTCHA_NOT_CONFIGURED", "请先在服务器填写对应端的腾讯云验证码配置并重启服务")
}
result, err := tx.Exec(ctx, `UPDATE login_captcha_policy SET saas_mode=$1,mini_mode=$2,version=version+1,updated_at=now() WHERE singleton AND version=$3`, in.SaaS, in.Mini, in.Version)
if err != nil {
return nil, err
}
if result.RowsAffected() != 1 {
return nil, fail(409, "CAPTCHA_POLICY_CONFLICT", "配置已被更新,请刷新后重试")
}
after := in
after.Version++
err = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.TenantID, ActorType: "user", ActorPublicID: &a.PublicID, Action: "login.captcha.policy.update", ResourceType: "login_captcha_policy", RequestID: requestID, Result: "success", Detail: map[string]any{"before": policy, "after": after}})
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
in.Version++
return s.captchaPolicyResult(in), nil
}
type storedCaptcha struct {
ID string `json:"id"`
Mode string `json:"mode"`
Client string `json:"client"`
Version int64 `json:"version"`
AppID string `json:"app_id,omitempty"`
AnswerHash string `json:"answer_hash,omitempty"`
Solution []byte `json:"slide_solution,omitempty"`
ProofHash string `json:"slide_proof_hash,omitempty"`
ExpiresAt int64 `json:"expires_at_ms,omitempty"`
}
func captchaJSON(v storedCaptcha) string { raw, _ := json.Marshal(v); return string(raw) }