158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
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) }
|