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)
|
|
}
|