package httpapi import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" ) const ( passwordRateLimitAttempts = 30 passwordRateLimitWindow = 15 * time.Minute ) // PasswordSessionIssuer is the HTTP adapter's consumer-owned Identity seam. type PasswordSessionIssuer interface { Login(context.Context, identity.LoginCommand) (identity.Session, error) } // PasswordAuthConfig contains transport configuration for password login. // CookieSecure retains the legacy string tri-state (recognized true/false or // empty/unrecognized for URL-based resolution). type PasswordAuthConfig struct { Configured bool SessionSecret string CookieSecure string PublicBaseURL string } type authPasswordHandler struct { config PasswordAuthConfig issuer PasswordSessionIssuer limiter *passwordIPLimiter } // NewAuthPasswordHandler builds the standalone password-login HTTP adapter. func NewAuthPasswordHandler(config PasswordAuthConfig, issuer PasswordSessionIssuer) (http.Handler, error) { if config.Configured && issuer == nil { return nil, fmt.Errorf("auth/password: configured authentication requires a session issuer") } return &authPasswordHandler{config: config, issuer: issuer, limiter: newPasswordIPLimiter(time.Now)}, nil } func (handler *authPasswordHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/auth/password" { w.WriteHeader(http.StatusNotFound) return } if r.Method == http.MethodOptions { w.Header().Set("Allow", "OPTIONS, POST") w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if !handler.config.Configured || strings.TrimSpace(handler.config.SessionSecret) == "" { writePasswordJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "账号认证配置不完整:ZHINIAN_AUTH_SESSION_SECRET"}) return } ip := passwordRequestIP(r) if !handler.limiter.allow(ip) { writePasswordJSON(w, http.StatusTooManyRequests, map[string]any{"error": "请求过于频繁,请稍后再试。"}) return } command, next := decodePasswordRequest(r) session, err := handler.issuer.Login(r.Context(), command) if err != nil { status, message, known := passwordLoginErrorResponse(err) if !known { writePasswordJSON(w, http.StatusInternalServerError, map[string]any{"error": "服务器内部错误。"}) return } writePasswordJSON(w, status, map[string]any{"error": message}) return } // A credential success resets the per-process attempt budget even if later // response serialization fails, matching the legacy lifecycle boundary. handler.limiter.clear(ip) rawSession, err := json.Marshal(session) if err != nil { writePasswordJSON(w, http.StatusInternalServerError, map[string]any{"error": "服务器内部错误。"}) return } signed, err := identity.Sign(rawSession, handler.config.SessionSecret) if err != nil { writePasswordJSON(w, http.StatusInternalServerError, map[string]any{"error": "服务器内部错误。"}) return } secure := identity.ResolveSecureCookie(handler.config.CookieSecure, handler.config.PublicBaseURL, absoluteRequestURL(r)) writes, err := identity.SetSessionCookies(signed, time.Unix(session.ExpiresAt, 0).UTC(), secure) if err != nil { writePasswordJSON(w, http.StatusInternalServerError, map[string]any{"error": "服务器内部错误。"}) return } // Complete all potentially failing serialization before mutating headers. response := map[string]any{ "ok": true, "redirectTo": safePasswordNext(next), "user": passwordPublicUser(session.User), "authMode": session.AuthMode, } payload, err := json.Marshal(response) if err != nil { writePasswordJSON(w, http.StatusInternalServerError, map[string]any{"error": "服务器内部错误。"}) return } for _, write := range writes { http.SetCookie(w, transportCookie(write)) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write(payload) } type passwordRequest struct { Phone any `json:"phone"` Username any `json:"username"` Password any `json:"password"` Next any `json:"next"` } func decodePasswordRequest(r *http.Request) (identity.LoginCommand, string) { var body passwordRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { return identity.LoginCommand{}, "" } phone := stringValue(body.Phone) if phone == "" { phone = stringValue(body.Username) } return identity.LoginCommand{Phone: phone, Password: stringValue(body.Password)}, stringValue(body.Next) } func stringValue(value any) string { text, ok := value.(string) if !ok { return "" } return strings.TrimSpace(text) } func safePasswordNext(value string) string { value = strings.TrimSpace(value) if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") { return "/create" } parsed, err := url.Parse(value) if err != nil || parsed.IsAbs() || parsed.Host != "" { return "/create" } // WHATWG URL parsing removes literal and percent-encoded dot segments but // otherwise preserves the original path escaping. Go exposes RawPath for // that representation, so normalize its segments without decoding %2F. canonicalPath := canonicalPasswordPath(parsed.EscapedPath()) if strings.HasPrefix(canonicalPath, "/api/auth") || strings.HasPrefix(canonicalPath, "/auth/login") || strings.HasPrefix(canonicalPath, "/auth/admin-login") { return "/create" } result := canonicalPath if parsed.RawQuery != "" { result += "?" + parsed.RawQuery } if parsed.Fragment != "" { result += "#" + parsed.EscapedFragment() } return result } func canonicalPasswordPath(value string) string { segments := strings.Split(value, "/") canonical := make([]string, 0, len(segments)) trailingDot := false for _, segment := range segments { switch { case passwordDotSegment(segment) == 1: trailingDot = true continue case passwordDotSegment(segment) == 2: if len(canonical) > 1 { canonical = canonical[:len(canonical)-1] } trailingDot = true default: canonical = append(canonical, segment) trailingDot = false } } if trailingDot { canonical = append(canonical, "") } result := strings.Join(canonical, "/") if result == "" { return "/" } return result } func passwordDotSegment(segment string) int { switch strings.ToLower(segment) { case ".", "%2e": return 1 case "..", ".%2e", "%2e.", "%2e%2e": return 2 default: return 0 } } func passwordPublicUser(user identity.User) publicUser { authorities := user.Authorities if authorities == nil { authorities = []string{} } scope := user.Scope if scope == nil { scope = []string{} } return publicUser{ ID: user.ID, Subject: user.Subject, Username: user.Username, Phone: user.Phone, DisplayName: user.DisplayName, ClientID: user.ClientID, OrganizationID: user.OrganizationID, OrganizationName: user.OrganizationName, Role: user.Role, Status: user.Status, Authorities: authorities, Scope: scope, } } func passwordLoginErrorResponse(err error) (int, string, bool) { var loginErr *identity.PasswordLoginError if !errors.As(err, &loginErr) { return 0, "", false } switch loginErr.Reason { case identity.LoginFailureInvalidInput: return http.StatusBadRequest, "手机号和密码不能为空。", true case identity.LoginFailureInvalidCredentials: return http.StatusUnauthorized, "手机号或密码错误。", true case identity.LoginFailureAccountDisabled: return http.StatusForbidden, "账号已停用,请联系管理员。", true case identity.LoginFailureInvalidRole, identity.LoginFailureOrganizationRequired, identity.LoginFailureOrganizationNotActive: return http.StatusForbidden, "所属组织已停用,请联系管理员。", true case identity.LoginFailureAccountLocked: return http.StatusLocked, "登录失败次数过多,请 15 分钟后再试。", true default: return 0, "", false } } func writePasswordJSON(w http.ResponseWriter, status int, value any) { payload, err := json.Marshal(value) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(payload) } func transportCookie(write identity.CookieWrite) *http.Cookie { cookie := &http.Cookie{Name: write.Name, Value: write.Value, Path: write.Attributes.Path, HttpOnly: write.Attributes.HTTPOnly, Secure: write.Attributes.Secure} if strings.EqualFold(write.Attributes.SameSite, "lax") { cookie.SameSite = http.SameSiteLaxMode } if write.Attributes.Expires != nil { cookie.Expires = *write.Attributes.Expires } if write.Attributes.MaxAgeSeconds != nil { if *write.Attributes.MaxAgeSeconds == 0 { cookie.MaxAge = -1 } else { cookie.MaxAge = *write.Attributes.MaxAgeSeconds } } return cookie } func absoluteRequestURL(r *http.Request) string { if r.URL.IsAbs() { return r.URL.String() } scheme := "http" if r.TLS != nil { scheme = "https" } return scheme + "://" + r.Host + r.URL.RequestURI() } func passwordRequestIP(r *http.Request) string { if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { return forwarded } if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" { return realIP } return "unknown" } type passwordRateLimitEntry struct { count int expiresAt time.Time } type passwordIPLimiter struct { mu sync.Mutex entries map[string]passwordRateLimitEntry now func() time.Time } func newPasswordIPLimiter(now func() time.Time) *passwordIPLimiter { return &passwordIPLimiter{entries: make(map[string]passwordRateLimitEntry), now: now} } func (limiter *passwordIPLimiter) allow(ip string) bool { limiter.mu.Lock() defer limiter.mu.Unlock() now := limiter.now() entry, ok := limiter.entries[ip] if !ok || !now.Before(entry.expiresAt) { entry = passwordRateLimitEntry{expiresAt: now.Add(passwordRateLimitWindow)} } if entry.count >= passwordRateLimitAttempts { limiter.entries[ip] = entry return false } entry.count++ limiter.entries[ip] = entry return true } func (limiter *passwordIPLimiter) clear(ip string) { limiter.mu.Lock() defer limiter.mu.Unlock() delete(limiter.entries, ip) }