feat: add Go password session lifecycle
This commit is contained in:
55
backend/internal/httpapi/auth_logout.go
Normal file
55
backend/internal/httpapi/auth_logout.go
Normal 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())
|
||||
}
|
||||
85
backend/internal/httpapi/auth_logout_test.go
Normal file
85
backend/internal/httpapi/auth_logout_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
343
backend/internal/httpapi/auth_password.go
Normal file
343
backend/internal/httpapi/auth_password.go
Normal 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)
|
||||
}
|
||||
354
backend/internal/httpapi/auth_password_test.go
Normal file
354
backend/internal/httpapi/auth_password_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user