Files
NianAIGC/backend/internal/httpapi/auth_password.go

344 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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