344 lines
10 KiB
Go
344 lines
10 KiB
Go
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)
|
||
}
|