feat: add Go password session lifecycle

This commit is contained in:
2026-08-13 15:34:46 +08:00
parent 772795e7eb
commit d0207fcebe
19 changed files with 2483 additions and 16 deletions

View File

@@ -0,0 +1,55 @@
package httpapi
import (
"net/http"
"net/url"
"strings"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
)
type LogoutConfig struct {
CookieSecure string
PublicBaseURL string
}
type authLogoutHandler struct{ config LogoutConfig }
func NewAuthLogoutHandler(config LogoutConfig) http.Handler {
return &authLogoutHandler{config: config}
}
func (handler *authLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/auth/logout" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method == http.MethodOptions {
w.Header().Set("Allow", "GET, HEAD, OPTIONS, POST")
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
requestURL := absoluteRequestURL(r)
secure := identity.ResolveSecureCookie(handler.config.CookieSecure, handler.config.PublicBaseURL, requestURL)
for _, write := range identity.ClearSessionCookies(secure) {
http.SetCookie(w, transportCookie(write))
}
w.Header().Set("Location", logoutLocation(requestURL))
w.WriteHeader(http.StatusTemporaryRedirect)
}
func logoutLocation(requestURL string) string {
parsed, err := url.Parse(requestURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "/auth/login?loggedOut=1"
}
parsed.Path = "/auth/login"
parsed.RawPath = ""
parsed.RawQuery = "loggedOut=1"
parsed.Fragment = ""
return strings.TrimSpace(parsed.String())
}

View File

@@ -0,0 +1,85 @@
package httpapi_test
import (
"net/http"
"net/http/httptest"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi"
)
type logoutContract struct {
Version int `json:"version"`
Path string `json:"path"`
Methods []string `json:"methods"`
Status int `json:"status"`
Location string `json:"location"`
RequiresAuthentication bool `json:"requiresAuthentication"`
DuplicateBaseCookieWrite bool `json:"duplicateBaseCookieWrite"`
}
func TestAuthLogoutConsumesSharedContractAndClearsExactlyTwentyCookies(t *testing.T) {
var contract logoutContract
loadHTTPFixture(t, "logout-v1.json", &contract)
cookie := loadPasswordSessionCookieContract(t)
handler := httpapi.NewAuthLogoutHandler(httpapi.LogoutConfig{PublicBaseURL: "https://app.example.test"})
if contract.Version != 1 || contract.RequiresAuthentication || contract.DuplicateBaseCookieWrite {
t.Fatalf("invalid shared logout contract: %+v", contract)
}
for _, method := range contract.Methods {
t.Run(method, func(t *testing.T) {
request := httptest.NewRequest(method, "https://app.example.test"+contract.Path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != contract.Status || response.Header().Get("Location") != contract.Location || response.Body.Len() != 0 {
t.Fatalf("response = %d location=%q body=%q", response.Code, response.Header().Get("Location"), response.Body.String())
}
cookies := response.Result().Cookies()
if len(cookies) != cookie.Cookie.MaxChunks {
t.Fatalf("cookies = %d, want %d", len(cookies), cookie.Cookie.MaxChunks)
}
for index, got := range cookies {
if got.Name != cookie.Cookie.ChunkNames[index] || got.Value != cookie.Cookie.Clear.Value || got.MaxAge != -1 || !got.HttpOnly || got.SameSite != http.SameSiteLaxMode || got.Path != "/" || !got.Secure {
t.Errorf("cookie %d = %#v", index, got)
}
}
})
}
}
func TestAuthLogoutUsesRequestOriginAndExplicitCookieSecurity(t *testing.T) {
handler := httpapi.NewAuthLogoutHandler(httpapi.LogoutConfig{CookieSecure: "false"})
request := httptest.NewRequest(http.MethodPost, "https://request.example.test/api/auth/logout", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Header().Get("Location") != "https://request.example.test/auth/login?loggedOut=1" {
t.Fatalf("Location = %q", response.Header().Get("Location"))
}
for _, cookie := range response.Result().Cookies() {
if cookie.Secure {
t.Fatalf("cookie unexpectedly secure: %#v", cookie)
}
}
}
func TestAuthLogoutMethodAndPathSemantics(t *testing.T) {
handler := httpapi.NewAuthLogoutHandler(httpapi.LogoutConfig{})
tests := []struct {
method, path string
status int
}{
{http.MethodOptions, "/api/auth/logout", http.StatusNoContent},
{http.MethodHead, "/api/auth/logout", http.StatusTemporaryRedirect},
{http.MethodPut, "/api/auth/logout", http.StatusMethodNotAllowed},
{http.MethodGet, "/api/auth/logout/", http.StatusNotFound},
}
for _, test := range tests {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, "http://app.test"+test.path, nil))
if response.Code != test.status || response.Body.Len() != 0 {
t.Errorf("%s %s = %d %q", test.method, test.path, response.Code, response.Body.String())
}
}
}

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

View File

@@ -0,0 +1,354 @@
package httpapi_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
)
type passwordContract struct {
Version int `json:"version"`
Path string `json:"path"`
Method string `json:"method"`
LocalSessionTTLSeconds int64 `json:"localSessionTtlSeconds"`
InputCases []struct {
Name string `json:"name"`
Body map[string]any `json:"body"`
ExpectedRedirect string `json:"expectedRedirect"`
} `json:"inputCases"`
SafeNextCases []struct {
Input *string `json:"input"`
Expected string `json:"expected"`
} `json:"safeNextCases"`
Success struct {
TopLevelKeys []string `json:"topLevelKeys"`
PublicUserKeys []string `json:"publicUserKeys"`
ForbiddenSerializedKeys []string `json:"forbiddenSerializedKeys"`
} `json:"success"`
Errors struct {
InvalidInput fixtureError `json:"invalidInput"`
InvalidCredentials fixtureError `json:"invalidCredentials"`
DisabledAccount fixtureError `json:"disabledAccount"`
DisabledOrganization fixtureError `json:"disabledOrganization"`
LockedAccount fixtureError `json:"lockedAccount"`
RateLimited fixtureError `json:"rateLimited"`
Unconfigured fixtureError `json:"unconfigured"`
} `json:"errors"`
RateLimit struct {
AttemptsPerIP int `json:"attemptsPerIp"`
WindowSeconds int `json:"windowSeconds"`
} `json:"rateLimit"`
}
type fixtureError struct {
Status int `json:"status"`
Body map[string]string `json:"body"`
}
type passwordIssuerStub struct {
session identity.Session
err error
commands []identity.LoginCommand
}
func (stub *passwordIssuerStub) Login(_ context.Context, command identity.LoginCommand) (identity.Session, error) {
stub.commands = append(stub.commands, command)
return stub.session, stub.err
}
func TestAuthPasswordConsumesSharedInputAndRedirectContract(t *testing.T) {
contract := loadPasswordContract(t)
for _, test := range contract.InputCases {
t.Run(test.Name, func(t *testing.T) {
issuer := &passwordIssuerStub{session: fixtureLoginSession()}
handler := newPasswordHandler(t, issuer)
body, _ := json.Marshal(test.Body)
response := servePassword(handler, contract.Path, body, "198.51.100.1")
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%q, want 200", response.Code, response.Body.String())
}
if len(issuer.commands) != 1 || issuer.commands[0].Phone != "13800138000" || issuer.commands[0].Password != "TestPass123" {
t.Fatalf("commands = %#v", issuer.commands)
}
var payload map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["redirectTo"] != test.ExpectedRedirect || payload["authMode"] != string(identity.AuthModeUser) || payload["ok"] != true {
t.Fatalf("payload = %#v", payload)
}
assertKeySet(t, payload, contract.Success.TopLevelKeys)
assertKeySet(t, payload["user"].(map[string]any), contract.Success.PublicUserKeys)
for _, forbidden := range contract.Success.ForbiddenSerializedKeys {
if strings.Contains(response.Body.String(), `"`+forbidden+`"`) {
t.Fatalf("response leaked forbidden key %q: %s", forbidden, response.Body.String())
}
}
})
}
}
func TestAuthPasswordConsumesSharedSafeNextContract(t *testing.T) {
contract := loadPasswordContract(t)
for _, test := range contract.SafeNextCases {
name := "null"
var next any
if test.Input != nil {
name, next = *test.Input, *test.Input
}
t.Run(name, func(t *testing.T) {
issuer := &passwordIssuerStub{session: fixtureLoginSession()}
handler := newPasswordHandler(t, issuer)
body, _ := json.Marshal(map[string]any{"phone": "13800138000", "password": "TestPass123", "next": next})
response := servePassword(handler, contract.Path, body, "198.51.100.2")
var payload struct {
RedirectTo string `json:"redirectTo"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || payload.RedirectTo != test.Expected {
t.Fatalf("response = %d %q, redirect=%q want %q", response.Code, response.Body.String(), payload.RedirectTo, test.Expected)
}
})
}
}
func TestAuthPasswordConsumesSharedErrorContract(t *testing.T) {
contract := loadPasswordContract(t)
tests := []struct {
name string
reason identity.PasswordLoginFailure
fixture fixtureError
}{
{"invalid input", identity.LoginFailureInvalidInput, contract.Errors.InvalidInput},
{"credentials", identity.LoginFailureInvalidCredentials, contract.Errors.InvalidCredentials},
{"disabled", identity.LoginFailureAccountDisabled, contract.Errors.DisabledAccount},
{"invalid role", identity.LoginFailureInvalidRole, contract.Errors.DisabledOrganization},
{"organization required", identity.LoginFailureOrganizationRequired, contract.Errors.DisabledOrganization},
{"organization inactive", identity.LoginFailureOrganizationNotActive, contract.Errors.DisabledOrganization},
{"locked", identity.LoginFailureAccountLocked, contract.Errors.LockedAccount},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(test.reason)}
response := servePassword(newPasswordHandler(t, issuer), contract.Path, []byte(`{"phone":"13800138000","password":"bad"}`), "198.51.100.3")
assertErrorFixture(t, response, test.fixture)
})
}
unconfiguredIssuer := &passwordIssuerStub{}
response := servePassword(newPasswordHandlerWithConfig(t, httpapi.PasswordAuthConfig{}, unconfiguredIssuer), contract.Path, []byte(`{}`), "198.51.100.4")
assertErrorFixture(t, response, contract.Errors.Unconfigured)
if len(unconfiguredIssuer.commands) != 0 {
t.Fatalf("unconfigured issuer calls = %d, want 0", len(unconfiguredIssuer.commands))
}
}
func TestAuthPasswordNullAndNonObjectJSONAreSafeInvalidInput(t *testing.T) {
contract := loadPasswordContract(t)
for _, body := range []string{"null", `[]`, `{not-json`} {
issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(identity.LoginFailureInvalidInput)}
response := servePassword(newPasswordHandler(t, issuer), contract.Path, []byte(body), "198.51.100.7")
assertErrorFixture(t, response, contract.Errors.InvalidInput)
}
}
func TestAuthPasswordRateLimitUsesForwardedIPAndClearsOnSuccess(t *testing.T) {
contract := loadPasswordContract(t)
issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(identity.LoginFailureInvalidCredentials)}
handler := newPasswordHandler(t, issuer)
requestBody := []byte(`{"phone":"13800138000","password":"bad"}`)
for index := 0; index < contract.RateLimit.AttemptsPerIP; index++ {
response := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{
"X-Forwarded-For": "203.0.113.9, 10.0.0.4",
"X-Real-IP": "192.0.2.20",
})
if response.Code != contract.Errors.InvalidCredentials.Status {
t.Fatalf("attempt %d status = %d", index+1, response.Code)
}
}
limited := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{"X-Forwarded-For": "203.0.113.9"})
assertErrorFixture(t, limited, contract.Errors.RateLimited)
if len(issuer.commands) != contract.RateLimit.AttemptsPerIP {
t.Fatalf("issuer calls = %d, want %d", len(issuer.commands), contract.RateLimit.AttemptsPerIP)
}
issuer.err = nil
issuer.session = fixtureLoginSession()
success := servePasswordWithHeaders(handler, contract.Path, []byte(`{"phone":"13800138000","password":"ok"}`), map[string]string{"X-Forwarded-For": "203.0.113.10"})
if success.Code != http.StatusOK {
t.Fatalf("success status = %d", success.Code)
}
issuer.err = identity.NewPasswordLoginError(identity.LoginFailureInvalidCredentials)
afterSuccess := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{"X-Forwarded-For": "203.0.113.10"})
if afterSuccess.Code != contract.Errors.InvalidCredentials.Status {
t.Fatalf("after success status = %d", afterSuccess.Code)
}
}
func TestAuthPasswordWritesTwentyFixtureCookiesAfterSuccessfulSign(t *testing.T) {
contract := loadPasswordContract(t)
cookie := loadPasswordSessionCookieContract(t)
issuer := &passwordIssuerStub{session: fixtureLoginSession()}
config := httpapi.PasswordAuthConfig{Configured: true, SessionSecret: cookie.Secret, PublicBaseURL: "https://app.example.test"}
response := servePassword(newPasswordHandlerWithConfig(t, config, issuer), contract.Path, []byte(`{"phone":"13800138000","password":"ok"}`), "198.51.100.5")
cookies := response.Result().Cookies()
if response.Code != http.StatusOK || len(cookies) != cookie.Cookie.MaxChunks {
t.Fatalf("status/cookies = %d/%d, want 200/%d", response.Code, len(cookies), cookie.Cookie.MaxChunks)
}
for index, got := range cookies {
if got.Name != cookie.Cookie.ChunkNames[index] || got.Path != cookie.Cookie.Attributes.Path || got.HttpOnly != cookie.Cookie.Attributes.HTTPOnly || got.SameSite != http.SameSiteLaxMode || !got.Secure {
t.Errorf("cookie %d = %#v", index, got)
}
if index == 0 {
if got.Value == "" || got.Expires.Unix() != fixtureLoginSession().ExpiresAt {
t.Errorf("session cookie = %#v", got)
}
} else if got.Value != cookie.Cookie.Clear.Value || got.MaxAge != -1 {
t.Errorf("clear cookie %d = %#v", index, got)
}
}
}
func TestAuthPasswordInfrastructureFailureIsGenericAndWritesNoCookie(t *testing.T) {
issuer := &passwordIssuerStub{err: errors.New("postgres password=secret")}
response := servePassword(newPasswordHandler(t, issuer), "/api/auth/password", []byte(`{"phone":"13800138000","password":"ok"}`), "198.51.100.6")
if response.Code != http.StatusInternalServerError || response.Body.String() != `{"error":"服务器内部错误。"}` || len(response.Result().Cookies()) != 0 {
t.Fatalf("response = %d %q cookies=%d", response.Code, response.Body.String(), len(response.Result().Cookies()))
}
}
func TestAuthPasswordMethodAndPathContract(t *testing.T) {
contract := loadPasswordContract(t)
handler := newPasswordHandler(t, &passwordIssuerStub{session: fixtureLoginSession()})
tests := []struct {
method, path string
status int
}{
{http.MethodGet, contract.Path, http.StatusMethodNotAllowed},
{http.MethodPut, contract.Path, http.StatusMethodNotAllowed},
{http.MethodOptions, contract.Path, http.StatusNoContent},
{http.MethodPost, contract.Path + "/", http.StatusNotFound},
}
for _, test := range tests {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.status || response.Body.Len() != 0 {
t.Errorf("%s %s = %d %q", test.method, test.path, response.Code, response.Body.String())
}
}
}
func newPasswordHandler(t *testing.T, issuer httpapi.PasswordSessionIssuer) http.Handler {
t.Helper()
cookie := loadPasswordSessionCookieContract(t)
return newPasswordHandlerWithConfig(t, httpapi.PasswordAuthConfig{Configured: true, SessionSecret: cookie.Secret}, issuer)
}
func newPasswordHandlerWithConfig(t *testing.T, config httpapi.PasswordAuthConfig, issuer httpapi.PasswordSessionIssuer) http.Handler {
t.Helper()
handler, err := httpapi.NewAuthPasswordHandler(config, issuer)
if err != nil {
t.Fatalf("NewAuthPasswordHandler() error = %v", err)
}
return handler
}
func servePassword(handler http.Handler, path string, body []byte, ip string) *httptest.ResponseRecorder {
return servePasswordWithHeaders(handler, path, body, map[string]string{"X-Real-IP": ip})
}
func servePasswordWithHeaders(handler http.Handler, path string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func fixtureLoginSession() identity.Session {
version := 7
return identity.Session{Version: 1, AuthMode: identity.AuthModeUser, IssuedAt: 1_700_000_000, ExpiresAt: 1_700_086_400, SessionVersion: &version, User: identity.User{
ID: "account-1", Subject: "account-1", Username: "13800138000", Phone: "13800138000", DisplayName: "张三", ClientID: "platform", OrganizationID: "org-1", OrganizationName: "组织一", Role: "user", Status: "active", Authorities: []string{"ROLE_USER"}, Scope: []string{},
}}
}
func loadPasswordContract(t *testing.T) passwordContract {
t.Helper()
var fixture passwordContract
loadHTTPFixture(t, "password-login-v1.json", &fixture)
return fixture
}
type passwordSessionFixture struct {
Secret string `json:"secret"`
Cookie struct {
MaxChunks int `json:"maxChunks"`
ChunkNames []string `json:"chunkNames"`
Attributes struct {
HTTPOnly bool `json:"httpOnly"`
SameSite string `json:"sameSite"`
Path string `json:"path"`
} `json:"attributes"`
Clear struct {
Value string `json:"value"`
} `json:"clear"`
} `json:"cookie"`
}
func loadPasswordSessionCookieContract(t *testing.T) passwordSessionFixture {
t.Helper()
var fixture passwordSessionFixture
loadHTTPFixture(t, "session-cookie-v1.json", &fixture)
return fixture
}
func loadHTTPFixture(t *testing.T, name string, target any) {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "auth", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("decode %s: %v", name, err)
}
}
func assertKeySet(t *testing.T, got map[string]any, want []string) {
t.Helper()
keys := make([]string, 0, len(got))
for key := range got {
keys = append(keys, key)
}
if len(keys) != len(want) {
t.Fatalf("keys = %v, want %v", keys, want)
}
for _, key := range want {
if _, ok := got[key]; !ok {
t.Fatalf("missing key %q in %v", key, keys)
}
}
}
func assertErrorFixture(t *testing.T, response *httptest.ResponseRecorder, fixture fixtureError) {
t.Helper()
want, _ := json.Marshal(fixture.Body)
if response.Code != fixture.Status || strings.TrimSpace(response.Body.String()) != string(want) {
t.Fatalf("response = %d %q, want %d %s", response.Code, response.Body.String(), fixture.Status, want)
}
}