feat: add Go password session lifecycle
This commit is contained in:
1 parent
772795e7eb
commit
d0207fcebe
19 files changed
+2483
-16
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user