feat: add Go password session lifecycle

This commit is contained in:
zn-admin committed 2026-08-13 15:34:46 +08:00
1 parent 772795e7eb
commit d0207fcebe
19 files changed
+2483 -16

No files matched your search

+343
View File
@@ -0,0 +1,343 @@
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)
}