From d0207fcebe6ea4fb3ba80dce8c012b3c2170de40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=97=E7=90=A6?= <442782435@qq.com> Date: Thu, 13 Aug 2026 15:34:46 +0800 Subject: [PATCH] feat: add Go password session lifecycle --- .../20260813-go-auth-lifecycle-3f9a6c12.md | 59 +++ backend/README.md | 25 +- backend/go.mod | 6 +- backend/internal/application/application.go | 28 ++ .../internal/application/application_test.go | 104 +++++ backend/internal/httpapi/auth_logout.go | 55 +++ backend/internal/httpapi/auth_logout_test.go | 85 ++++ backend/internal/httpapi/auth_password.go | 343 ++++++++++++++++ .../internal/httpapi/auth_password_test.go | 354 +++++++++++++++++ backend/internal/identity/password_login.go | 143 +++++++ .../internal/identity/password_login_test.go | 186 +++++++++ backend/internal/postgres/database.go | 22 +- backend/internal/postgres/open.go | 33 ++ backend/internal/postgres/password_login.go | 220 +++++++++++ .../internal/postgres/password_login_test.go | 370 ++++++++++++++++++ contracts/auth/logout-v1.json | 10 + contracts/auth/password-login-v1.json | 112 ++++++ tests/auth-logout-contract.test.ts | 84 ++++ tests/auth-password-login-contract.test.ts | 260 ++++++++++++ 19 files changed, 2483 insertions(+), 16 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260813-go-auth-lifecycle-3f9a6c12.md create mode 100644 backend/internal/httpapi/auth_logout.go create mode 100644 backend/internal/httpapi/auth_logout_test.go create mode 100644 backend/internal/httpapi/auth_password.go create mode 100644 backend/internal/httpapi/auth_password_test.go create mode 100644 backend/internal/identity/password_login.go create mode 100644 backend/internal/identity/password_login_test.go create mode 100644 backend/internal/postgres/password_login.go create mode 100644 backend/internal/postgres/password_login_test.go create mode 100644 contracts/auth/logout-v1.json create mode 100644 contracts/auth/password-login-v1.json create mode 100644 tests/auth-logout-contract.test.ts create mode 100644 tests/auth-password-login-contract.test.ts diff --git a/.project-docs/30-worklog/tasks/20260813-go-auth-lifecycle-3f9a6c12.md b/.project-docs/30-worklog/tasks/20260813-go-auth-lifecycle-3f9a6c12.md new file mode 100644 index 0000000..f958af7 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260813-go-auth-lifecycle-3f9a6c12.md @@ -0,0 +1,59 @@ +# Task: Implement Go password session lifecycle vertical slice + +## Identity + +- Task ID: 20260813-go-auth-lifecycle-3f9a6c12 +- Mode: Feature +- Branch: codex/20260813-go-auth-lifecycle-3f9a6c12-go-auth-lifecycle +- Worktree: /Users/brother7/Documents/AI/NianAIGC-go-auth-lifecycle-3f9a6c12 +- Base commit: 772795e7ebd519441d98111e555288d56b75032b +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Freeze the current platform password-login and logout HTTP/session lifecycle in language-neutral contracts consumed by TypeScript and Go tests. +- Add a deep Go Password Login Module that normalizes credentials, delegates one atomic login attempt, and creates the legacy version-one platform session. +- Add a PostgreSQL Adapter that preserves the existing per-account `FOR UPDATE` transaction, failed-attempt lockout, successful-state reset, organization checks, and scrypt password compatibility. +- Add Go HTTP Adapters for `POST /api/auth/password` and `GET|POST /api/auth/logout`, then compose them into the separately runnable Go process without moving production traffic. +- Keep self-service/admin password mutation, account administration, captcha/login redirect routes, Middleware replacement, local JSON authentication, and production cutover outside this slice. + +## Intent And Constraints + +- Use the external HTTP Handler, the Password Login Module's single `Login` Interface, and one atomic persistence `AttemptPasswordLogin` Interface as the agreed TDD seams. +- Preserve existing Cookie wire/chunk/attribute/TTL behavior and database-authoritative role/profile/sessionVersion claims; never expose hashes, salts, counters, tokens, or internal errors. +- Preserve PostgreSQL single-writer semantics: lock the account row; commit each failed-password transition; return 401 for failures one through four; on the fifth set a 15-minute lock, reset the counter to zero, commit, then return 423; successful login clears lock/fail state and updates `last_login_at` without rotating `session_version`. +- Preserve account and active-organization enforcement, while applying the already accepted rule that every non-super-admin must have a matching active organization. +- Match the existing Node scrypt format exactly (`N=16384`, `r=8`, `p=1`, key length 64; UTF-8 password and salt string; lowercase hex hash) so existing accounts can log in. +- Preserve the password route's request normalization, safe local redirect behavior, stable public response, 30-attempt per-process IP limiter, and configuration/error status mapping. Infrastructure or Cookie-writing failures remain server failures and must not masquerade as invalid credentials. +- Preserve logout's stateless 307 redirect and complete legacy Cookie clearing. No database session revocation is added. +- Keep ACK-001, Next.js Route Handlers, Docker/Compose/ACK/Ingress, Worker, Secrets, and production route ownership unchanged; do not dual-write login state in production. + +## Outcome + +- Added language-neutral password-login and logout HTTP/session contracts, with real TypeScript Route Handler consumers and Go black-box Handler consumers. +- Added the Go Password Login Module, which normalizes public credentials, delegates one atomic attempt, enforces exact platform role/organization rules, and creates a database-authoritative version-one session with a 24-hour lifetime. +- Added the PostgreSQL credential Adapter with an account-row `FOR UPDATE` transaction, Node-compatible scrypt verification, committed failed-attempt transitions, fifth-attempt lockout, and successful state reset. +- Added Go HTTP Adapters for password login and logout, including safe redirects, public response projection, per-IP throttling, complete signed/chunked Cookie writes, generic infrastructure failures, and all 20 legacy Cookie clears. +- Composed the two routes into the separately runnable Go process and updated its README. Next.js, Node Worker, Docker, ACK, Ingress, Secrets, and production route ownership remain unchanged. + +## Verification + +- `npm test`: PASS, 39 files and 148 tests. +- `npx tsc --noEmit --incremental false --pretty false`: PASS. +- `npm run go:test`: PASS across command, application, HTTP, Identity, and PostgreSQL packages. +- `npm run go:vet`: PASS. +- `npm run go:build`: PASS. +- `npm run build`: PASS; only the pre-existing multiple-lockfile workspace-root warning was emitted. +- `npm run deploy:check`: PASS for all eight ACK manifests. +- `gofmt -l backend`, `git diff --check`, and the production deployment/routing forbidden-scope diff: PASS. + +## Follow-ups + +- Exercise the PostgreSQL login transaction and verified-TLS path against a migrated non-production RDS instance before any path cutover. +- Decide whether organization status reads need an explicit row lock after measuring the real administration/login concurrency pattern; this slice intentionally matches the current transaction behavior. +- Migrate self-service and administrative password mutation in a later bounded slice. + +## Promotion Candidates + +- None recorded. diff --git a/backend/README.md b/backend/README.md index 9115a43..f639067 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,11 +7,13 @@ unchanged until later route-by-route cutover work passes the shared contracts. Implemented Modules: -- `identity`: legacy `zhinian_session` HMAC/chunking plus database-refreshed - account, organization, role, and `sessionVersion` authorization. -- `postgres`: fail-closed configuration, verified-CA TLS, readiness, and calls - to the existing atomic claim and wallet PostgreSQL functions. -- `httpapi`: process health, database readiness, and current-session handlers. +- `identity`: legacy `zhinian_session` HMAC/chunking, database-refreshed + authorization, and the password-login lifecycle. +- `postgres`: fail-closed configuration, verified-CA TLS, readiness, atomic + password lockout transactions, and calls to the existing claim and wallet + PostgreSQL functions. +- `httpapi`: process health, database readiness, current-session, password + login, and logout handlers. - `application`: composition and the `cmd/zhinian-api` process entry point. From the repository root: @@ -31,10 +33,11 @@ server, use a different port: ZHINIAN_DATA_BACKEND=local GO_BACKEND_PORT=8080 ./backend/zhinian-api ``` -`/api/health`, `/api/ready`, and `/api/auth/me` are implemented in the local Go -process. No Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go -yet, so Next.js remains the production owner of every route. +`/api/health`, `/api/ready`, `/api/auth/me`, `/api/auth/password`, and +`/api/auth/logout` are implemented in the separately runnable Go process. No +Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go yet, so +Next.js remains the production owner of every route. -The current-session handler reuses the Identity resolver and PostgreSQL -authorization-snapshot Adapter, but login, logout, password mutation, and -production route ownership remain with Next.js until later path-level cutover. +The authentication handlers reuse the shared Cookie contracts and PostgreSQL +Adapters. Self-service/admin password mutation and production route ownership +remain with Next.js until later path-level cutover. diff --git a/backend/go.mod b/backend/go.mod index 237f9b4..684693a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,13 +2,15 @@ module git.nianxx.cn/wangxuming/NianAIGC/backend go 1.21 -require github.com/jackc/pgx/v5 v5.5.5 +require ( + github.com/jackc/pgx/v5 v5.5.5 + golang.org/x/crypto v0.17.0 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect - golang.org/x/crypto v0.17.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go index de2eae2..09c1a42 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -19,6 +19,9 @@ type Options struct { // composition tests and alternate runtime backends. Production defaults to // the PostgreSQL Store opened below. AuthorizationLoader identity.AuthorizationSnapshotLoader + // CredentialAuthenticator is the narrow Password Login persistence seam. + // Production defaults to the same PostgreSQL Store used for authorization. + CredentialAuthenticator identity.CredentialAuthenticator } type App struct { @@ -73,9 +76,34 @@ func New(options Options) (*App, error) { if err != nil { return nil, err } + var passwordIssuer httpapi.PasswordSessionIssuer + if authConfig.Configured { + authenticator := options.CredentialAuthenticator + if authenticator == nil { + authenticator = database.Store + } + passwordIssuer = identity.NewPasswordLogin(authenticator, nil) + } + cookieSecure := getenv("ZHINIAN_AUTH_COOKIE_SECURE") + publicBaseURL := firstAuthEnv(getenv, "NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL") + authPassword, err := httpapi.NewAuthPasswordHandler(httpapi.PasswordAuthConfig{ + Configured: authConfig.Configured, + SessionSecret: authConfig.SessionSecret, + CookieSecure: cookieSecure, + PublicBaseURL: publicBaseURL, + }, passwordIssuer) + if err != nil { + return nil, err + } + authLogout := httpapi.NewAuthLogoutHandler(httpapi.LogoutConfig{ + CookieSecure: cookieSecure, + PublicBaseURL: publicBaseURL, + }) foundation := httpapi.NewHandler(readiness) mux := http.NewServeMux() mux.Handle("/api/auth/me", authMe) + mux.Handle("/api/auth/password", authPassword) + mux.Handle("/api/auth/logout", authLogout) mux.Handle("/", foundation) closeOnError = false return &App{ diff --git a/backend/internal/application/application_test.go b/backend/internal/application/application_test.go index 3d5f24b..a424694 100644 --- a/backend/internal/application/application_test.go +++ b/backend/internal/application/application_test.go @@ -258,6 +258,97 @@ func TestApplicationUsesDatabaseAuthorizationAdapterByDefault(t *testing.T) { } } +func TestApplicationComposesPasswordLoginAndLogoutHandlers(t *testing.T) { + secret := "application-password-login-secret-with-enough-entropy" + authenticator := &applicationCredentialAuthenticator{account: identity.LoginAccount{ + Account: identity.AccountSnapshot{ + ID: "user-1", Phone: "13800138000", DisplayName: "Login User", Role: "user", + OrganizationID: "org-1", Status: "active", SessionVersion: 9, + }, + Organization: &identity.OrganizationSnapshot{ID: "org-1", Name: "Primary Organization", Status: "active"}, + }} + app, err := application.New(application.Options{ + Getenv: applicationEnv(map[string]string{ + "ZHINIAN_DATA_BACKEND": "local", + "ZHINIAN_AUTH_SESSION_SECRET": secret, + "NEXT_PUBLIC_APP_URL": "https://public.example.test", + }), + CredentialAuthenticator: authenticator, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(app.Close) + + loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/password", strings.NewReader(`{ + "phone":" (138) 0013-8000 ", "password":" password ", "next":"/create?source=login" + }`)) + loginRequest.Header.Set("Content-Type", "application/json") + loginResponse := httptest.NewRecorder() + app.Handler().ServeHTTP(loginResponse, loginRequest) + + if loginResponse.Code != http.StatusOK { + t.Fatalf("login response = %d %q", loginResponse.Code, loginResponse.Body.String()) + } + if len(authenticator.phones) != 1 || authenticator.phones[0] != "13800138000" || authenticator.passwords[0] != "password" { + t.Fatalf("credential attempts = phones %#v passwords %#v", authenticator.phones, authenticator.passwords) + } + cookieValues := make(map[string]string) + for _, cookie := range loginResponse.Result().Cookies() { + if !cookie.Secure { + t.Fatalf("login cookie did not inherit HTTPS public base URL: %#v", cookie) + } + if cookie.Value != "" { + cookieValues[cookie.Name] = cookie.Value + } + } + signed, ok := identity.Reassemble(identity.SessionCookieName, func(name string) (string, bool) { + value, found := cookieValues[name] + return value, found + }) + if !ok { + t.Fatalf("login response did not contain a session cookie: %#v", loginResponse.Header().Values("Set-Cookie")) + } + session, err := identity.Parse(signed, secret, time.Now()) + if err != nil { + t.Fatalf("parse issued session: %v", err) + } + if session.User.ID != "user-1" || session.User.OrganizationName != "Primary Organization" || session.SessionVersion == nil || *session.SessionVersion != 9 { + t.Fatalf("session = %+v", session) + } + + logoutResponse := httptest.NewRecorder() + app.Handler().ServeHTTP(logoutResponse, httptest.NewRequest(http.MethodPost, "http://app.test/api/auth/logout", nil)) + if logoutResponse.Code != http.StatusTemporaryRedirect || logoutResponse.Header().Get("Location") != "http://app.test/auth/login?loggedOut=1" { + t.Fatalf("logout response = %d location=%q", logoutResponse.Code, logoutResponse.Header().Get("Location")) + } + if got := len(logoutResponse.Result().Cookies()); got != identity.CookieMaxChunks { + t.Fatalf("logout cookies = %d, want %d", got, identity.CookieMaxChunks) + } +} + +func TestApplicationLeavesLogoutAvailableWhenPasswordAuthenticationIsUnconfigured(t *testing.T) { + app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{ + "ZHINIAN_DATA_BACKEND": "local", + })}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + t.Cleanup(app.Close) + + passwordResponse := httptest.NewRecorder() + app.Handler().ServeHTTP(passwordResponse, httptest.NewRequest(http.MethodPost, "/api/auth/password", strings.NewReader(`{"phone":"13800138000","password":"password"}`))) + if passwordResponse.Code != http.StatusServiceUnavailable { + t.Fatalf("password status = %d body=%q, want 503", passwordResponse.Code, passwordResponse.Body.String()) + } + + logoutResponse := httptest.NewRecorder() + app.Handler().ServeHTTP(logoutResponse, httptest.NewRequest(http.MethodGet, "http://app.test/api/auth/logout", nil)) + if logoutResponse.Code != http.StatusTemporaryRedirect { + t.Fatalf("logout status = %d, want 307", logoutResponse.Code) + } +} + type applicationAuthorizationLoader struct { snapshot identity.AuthorizationSnapshot found bool @@ -265,6 +356,19 @@ type applicationAuthorizationLoader struct { ids []string } +type applicationCredentialAuthenticator struct { + account identity.LoginAccount + err error + phones []string + passwords []string +} + +func (authenticator *applicationCredentialAuthenticator) AttemptPasswordLogin(_ context.Context, phone, password string, _ time.Time) (identity.LoginAccount, error) { + authenticator.phones = append(authenticator.phones, phone) + authenticator.passwords = append(authenticator.passwords, password) + return authenticator.account, authenticator.err +} + func (loader *applicationAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (identity.AuthorizationSnapshot, bool, error) { loader.ids = append(loader.ids, id) return loader.snapshot, loader.found, loader.err diff --git a/backend/internal/httpapi/auth_logout.go b/backend/internal/httpapi/auth_logout.go new file mode 100644 index 0000000..829cbde --- /dev/null +++ b/backend/internal/httpapi/auth_logout.go @@ -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()) +} diff --git a/backend/internal/httpapi/auth_logout_test.go b/backend/internal/httpapi/auth_logout_test.go new file mode 100644 index 0000000..df479ef --- /dev/null +++ b/backend/internal/httpapi/auth_logout_test.go @@ -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()) + } + } +} diff --git a/backend/internal/httpapi/auth_password.go b/backend/internal/httpapi/auth_password.go new file mode 100644 index 0000000..baea925 --- /dev/null +++ b/backend/internal/httpapi/auth_password.go @@ -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) +} diff --git a/backend/internal/httpapi/auth_password_test.go b/backend/internal/httpapi/auth_password_test.go new file mode 100644 index 0000000..8d687b3 --- /dev/null +++ b/backend/internal/httpapi/auth_password_test.go @@ -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) + } +} diff --git a/backend/internal/identity/password_login.go b/backend/internal/identity/password_login.go new file mode 100644 index 0000000..dbc7a32 --- /dev/null +++ b/backend/internal/identity/password_login.go @@ -0,0 +1,143 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode" +) + +const passwordSessionTTL = 24 * time.Hour + +// CredentialAuthenticator is the Password Login Module's single persistence +// seam. Implementations own password verification and atomic lockout updates. +type CredentialAuthenticator interface { + AttemptPasswordLogin(context.Context, string, string, time.Time) (LoginAccount, error) +} + +// LoginAccount is the complete database-authoritative identity returned by a +// successful credential attempt. It deliberately excludes credential data. +type LoginAccount struct { + Account AccountSnapshot + Organization *OrganizationSnapshot +} + +type LoginCommand struct { + Phone string + Password string +} + +type PasswordLoginFailure string + +const ( + LoginFailureInvalidInput PasswordLoginFailure = "invalid_input" + LoginFailureInvalidCredentials PasswordLoginFailure = "invalid_credentials" + LoginFailureAccountDisabled PasswordLoginFailure = "account_disabled" + LoginFailureAccountLocked PasswordLoginFailure = "account_locked" + LoginFailureInvalidRole PasswordLoginFailure = "invalid_role" + LoginFailureOrganizationRequired PasswordLoginFailure = "organization_required" + LoginFailureOrganizationNotActive PasswordLoginFailure = "organization_not_active" +) + +var ErrPasswordLogin = errors.New("password login failed") + +type PasswordLoginError struct { + Reason PasswordLoginFailure +} + +func NewPasswordLoginError(reason PasswordLoginFailure) *PasswordLoginError { + return &PasswordLoginError{Reason: reason} +} + +func (err *PasswordLoginError) Error() string { + return fmt.Sprintf("%s: %s", ErrPasswordLogin, err.Reason) +} + +func (err *PasswordLoginError) Unwrap() error { + return ErrPasswordLogin +} + +type PasswordLogin struct { + authenticator CredentialAuthenticator + now func() time.Time +} + +func NewPasswordLogin(authenticator CredentialAuthenticator, now func() time.Time) *PasswordLogin { + if now == nil { + now = time.Now + } + return &PasswordLogin{authenticator: authenticator, now: now} +} + +// Login normalizes public input, delegates one atomic credential attempt, and +// creates the version-one platform session from database-authoritative data. +func (login *PasswordLogin) Login(ctx context.Context, command LoginCommand) (Session, error) { + phone := normalizeLoginPhone(command.Phone) + password := strings.TrimSpace(command.Password) + if login == nil || login.authenticator == nil { + return Session{}, fmt.Errorf("password login is not configured") + } + if phone == "" || password == "" { + return Session{}, NewPasswordLoginError(LoginFailureInvalidInput) + } + + now := login.now() + account, err := login.authenticator.AttemptPasswordLogin(ctx, phone, password, now) + if err != nil { + return Session{}, err + } + if account.Account.Status != "active" { + return Session{}, NewPasswordLoginError(LoginFailureAccountDisabled) + } + authMode, authorities, validRole := roleClaims(account.Account.Role) + if !validRole { + return Session{}, NewPasswordLoginError(LoginFailureInvalidRole) + } + + organizationName := "" + if account.Organization != nil && account.Organization.ID == account.Account.OrganizationID { + organizationName = account.Organization.Name + } + if account.Account.Role != "super_admin" { + if account.Account.OrganizationID == "" { + return Session{}, NewPasswordLoginError(LoginFailureOrganizationRequired) + } + if account.Organization == nil || account.Organization.ID != account.Account.OrganizationID || account.Organization.Status != "active" { + return Session{}, NewPasswordLoginError(LoginFailureOrganizationNotActive) + } + } + + sessionVersion := account.Account.SessionVersion + return Session{ + Version: 1, + AuthMode: authMode, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(passwordSessionTTL).Unix(), + SessionVersion: &sessionVersion, + User: User{ + ID: account.Account.ID, + Subject: account.Account.ID, + Username: account.Account.Phone, + Phone: account.Account.Phone, + DisplayName: account.Account.DisplayName, + ClientID: "platform", + OrganizationID: account.Account.OrganizationID, + OrganizationName: organizationName, + Role: account.Account.Role, + Status: account.Account.Status, + Authorities: authorities, + Scope: []string{}, + }, + }, nil +} + +func normalizeLoginPhone(value string) string { + return strings.Map(func(character rune) rune { + if unicode.IsSpace(character) || character == '(' || character == ')' || character == '-' { + return -1 + } + return character + }, strings.TrimSpace(value)) +} diff --git a/backend/internal/identity/password_login_test.go b/backend/internal/identity/password_login_test.go new file mode 100644 index 0000000..5d22955 --- /dev/null +++ b/backend/internal/identity/password_login_test.go @@ -0,0 +1,186 @@ +package identity + +import ( + "context" + "errors" + "reflect" + "testing" + "time" +) + +type recordingCredentialAuthenticator struct { + account LoginAccount + err error + phones []string + passwords []string + times []time.Time +} + +func (authenticator *recordingCredentialAuthenticator) AttemptPasswordLogin(_ context.Context, phone, password string, now time.Time) (LoginAccount, error) { + authenticator.phones = append(authenticator.phones, phone) + authenticator.passwords = append(authenticator.passwords, password) + authenticator.times = append(authenticator.times, now) + return authenticator.account, authenticator.err +} + +func TestPasswordLoginNormalizesCredentialsAndCreatesDatabaseAuthoritativeSession(t *testing.T) { + now := time.Date(2026, time.August, 13, 6, 7, 8, 0, time.UTC) + authenticator := &recordingCredentialAuthenticator{account: LoginAccount{ + Account: AccountSnapshot{ + ID: "user-1", Phone: "13800138000", DisplayName: "Database Name", Role: "user", + OrganizationID: "org-1", Status: "active", SessionVersion: 7, + }, + Organization: &OrganizationSnapshot{ID: "org-1", Name: "Database Organization", Status: "active"}, + }} + login := NewPasswordLogin(authenticator, func() time.Time { return now }) + + got, err := login.Login(context.Background(), LoginCommand{ + Phone: " (138) 0013-8000\t", Password: " correct-password ", + }) + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if !reflect.DeepEqual(authenticator.phones, []string{"13800138000"}) || + !reflect.DeepEqual(authenticator.passwords, []string{"correct-password"}) || + !reflect.DeepEqual(authenticator.times, []time.Time{now}) { + t.Fatalf("AttemptPasswordLogin calls = phones %#v passwords %#v times %#v", authenticator.phones, authenticator.passwords, authenticator.times) + } + wantVersion := 7 + want := Session{ + Version: 1, AuthMode: AuthModeUser, IssuedAt: now.Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(), + SessionVersion: &wantVersion, + User: User{ + ID: "user-1", Subject: "user-1", Username: "13800138000", Phone: "13800138000", + DisplayName: "Database Name", ClientID: "platform", OrganizationID: "org-1", + OrganizationName: "Database Organization", Role: "user", Status: "active", + Authorities: []string{"ROLE_USER"}, Scope: []string{}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Login() session mismatch\n got: %#v\nwant: %#v", got, want) + } +} + +func TestPasswordLoginRejectsInvalidInputBeforeCredentialAttempt(t *testing.T) { + tests := []LoginCommand{ + {Phone: " \t() - ", Password: "password"}, + {Phone: "13800138000", Password: " \n "}, + } + for _, command := range tests { + authenticator := &recordingCredentialAuthenticator{} + _, err := NewPasswordLogin(authenticator, nil).Login(context.Background(), command) + assertPasswordLoginFailure(t, err, LoginFailureInvalidInput) + if len(authenticator.phones) != 0 { + t.Fatalf("AttemptPasswordLogin called for invalid command %#v", command) + } + } +} + +func TestPasswordLoginPreservesTypedCredentialFailureAndInfrastructureError(t *testing.T) { + locked := NewPasswordLoginError(LoginFailureAccountLocked) + databaseErr := errors.New("database unavailable") + for _, testCase := range []struct { + name string + err error + }{ + {name: "typed credential rejection", err: locked}, + {name: "infrastructure failure", err: databaseErr}, + } { + t.Run(testCase.name, func(t *testing.T) { + authenticator := &recordingCredentialAuthenticator{err: testCase.err} + _, err := NewPasswordLogin(authenticator, nil).Login(context.Background(), LoginCommand{Phone: "13800138000", Password: "password"}) + if !errors.Is(err, testCase.err) { + t.Fatalf("Login() error = %v, want original %v", err, testCase.err) + } + if testCase.err == databaseErr && errors.Is(err, ErrPasswordLogin) { + t.Fatal("infrastructure failure must not become a login rejection") + } + }) + } +} + +func TestPasswordLoginClassifiesDatabaseAccountAndOrganization(t *testing.T) { + activeOrganization := &OrganizationSnapshot{ID: "org-1", Name: "Organization", Status: "active"} + tests := []struct { + name string + account AccountSnapshot + organization *OrganizationSnapshot + want PasswordLoginFailure + }{ + {name: "disabled account", account: AccountSnapshot{ID: "user", Role: "user", Status: "disabled", OrganizationID: "org-1"}, organization: activeOrganization, want: LoginFailureAccountDisabled}, + {name: "invalid role", account: AccountSnapshot{ID: "user", Role: "owner", Status: "active"}, want: LoginFailureInvalidRole}, + {name: "non-super missing organization", account: AccountSnapshot{ID: "user", Role: "user", Status: "active"}, want: LoginFailureOrganizationRequired}, + {name: "organization missing", account: AccountSnapshot{ID: "user", Role: "user", Status: "active", OrganizationID: "org-1"}, want: LoginFailureOrganizationNotActive}, + {name: "organization mismatched", account: AccountSnapshot{ID: "user", Role: "user", Status: "active", OrganizationID: "org-1"}, organization: &OrganizationSnapshot{ID: "org-2", Status: "active"}, want: LoginFailureOrganizationNotActive}, + {name: "organization disabled", account: AccountSnapshot{ID: "user", Role: "organization_admin", Status: "active", OrganizationID: "org-1"}, organization: &OrganizationSnapshot{ID: "org-1", Status: "disabled"}, want: LoginFailureOrganizationNotActive}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + authenticator := &recordingCredentialAuthenticator{account: LoginAccount{Account: testCase.account, Organization: testCase.organization}} + _, err := NewPasswordLogin(authenticator, nil).Login(context.Background(), LoginCommand{Phone: "13800138000", Password: "password"}) + assertPasswordLoginFailure(t, err, testCase.want) + }) + } +} + +func TestPasswordLoginMapsEachExactRoleAndAllowsUnboundSuperAdministrator(t *testing.T) { + now := time.Unix(100, 0) + tests := []struct { + name string + role string + organizationID string + organization *OrganizationSnapshot + authMode AuthMode + authorities []string + }{ + {name: "user", role: "user", organizationID: "org-1", organization: &OrganizationSnapshot{ID: "org-1", Name: "Org", Status: "active"}, authMode: AuthModeUser, authorities: []string{"ROLE_USER"}}, + {name: "organization administrator", role: "organization_admin", organizationID: "org-1", organization: &OrganizationSnapshot{ID: "org-1", Name: "Org", Status: "active"}, authMode: AuthModeAdmin, authorities: []string{"ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"}}, + {name: "unbound super administrator", role: "super_admin", authMode: AuthModeAdmin, authorities: []string{"ROLE_SUPER_ADMIN", "SUPER_ADMIN"}}, + {name: "bound super administrator", role: "super_admin", organizationID: "org-1", organization: &OrganizationSnapshot{ID: "org-1", Name: "Org", Status: "disabled"}, authMode: AuthModeAdmin, authorities: []string{"ROLE_SUPER_ADMIN", "SUPER_ADMIN"}}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + authenticator := &recordingCredentialAuthenticator{account: LoginAccount{ + Account: AccountSnapshot{ID: "account-1", Phone: "13800138000", DisplayName: "Name", Role: testCase.role, OrganizationID: testCase.organizationID, Status: "active", SessionVersion: 2}, + Organization: testCase.organization, + }} + session, err := NewPasswordLogin(authenticator, func() time.Time { return now }).Login(context.Background(), LoginCommand{Phone: "13800138000", Password: "password"}) + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if session.AuthMode != testCase.authMode || !reflect.DeepEqual(session.User.Authorities, testCase.authorities) { + t.Fatalf("role claims = (%q, %#v), want (%q, %#v)", session.AuthMode, session.User.Authorities, testCase.authMode, testCase.authorities) + } + if session.User.OrganizationID != testCase.organizationID { + t.Fatalf("organization ID = %q, want %q", session.User.OrganizationID, testCase.organizationID) + } + wantOrganizationName := "" + if testCase.organization != nil { + wantOrganizationName = testCase.organization.Name + } + if session.User.OrganizationName != wantOrganizationName { + t.Fatalf("organization name = %q, want %q", session.User.OrganizationName, wantOrganizationName) + } + }) + } +} + +func TestPasswordLoginFailsClosedWhenNotConfigured(t *testing.T) { + for _, login := range []*PasswordLogin{nil, NewPasswordLogin(nil, nil)} { + _, err := login.Login(context.Background(), LoginCommand{Phone: "13800138000", Password: "password"}) + if err == nil || errors.Is(err, ErrPasswordLogin) { + t.Fatalf("Login() error = %v, want configuration failure", err) + } + } +} + +func assertPasswordLoginFailure(t *testing.T, err error, want PasswordLoginFailure) { + t.Helper() + var loginErr *PasswordLoginError + if !errors.As(err, &loginErr) || !errors.Is(err, ErrPasswordLogin) { + t.Fatalf("Login() error = %v, want typed password login error", err) + } + if loginErr.Reason != want { + t.Fatalf("Login() failure = %q, want %q", loginErr.Reason, want) + } +} diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go index 901394e..2078c84 100644 --- a/backend/internal/postgres/database.go +++ b/backend/internal/postgres/database.go @@ -56,20 +56,36 @@ type Querier interface { Query(context.Context, string, ...any) (Rows, error) } +type Transaction interface { + Querier + Exec(context.Context, string, ...any) error + Commit(context.Context) error + Rollback(context.Context) error +} + +type TransactionBeginner interface { + Begin(context.Context) (Transaction, error) +} + type Pool interface { Querier Close() } type Database struct { - config Config - querier Querier + config Config + querier Querier + transactions TransactionBeginner } type Store = Database func NewDatabase(config Config, querier Querier) *Database { - return &Database{config: config, querier: querier} + db := &Database{config: config, querier: querier} + if transactions, ok := querier.(TransactionBeginner); ok { + db.transactions = transactions + } + return db } func (db *Database) Readiness(ctx context.Context) error { diff --git a/backend/internal/postgres/open.go b/backend/internal/postgres/open.go index 8270bef..46ee24d 100644 --- a/backend/internal/postgres/open.go +++ b/backend/internal/postgres/open.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -68,8 +69,40 @@ func (p *pgxPoolAdapter) Query(ctx context.Context, sql string, args ...any) (Ro return p.pool.Query(ctx, sql, args...) } +func (p *pgxPoolAdapter) Begin(ctx context.Context) (Transaction, error) { + tx, err := p.pool.Begin(ctx) + if err != nil { + return nil, err + } + return &pgxTransactionAdapter{tx: tx}, nil +} + func (p *pgxPoolAdapter) Close() { p.pool.Close() } var _ Pool = (*pgxPoolAdapter)(nil) +var _ TransactionBeginner = (*pgxPoolAdapter)(nil) + +type pgxTransactionAdapter struct { + tx pgx.Tx +} + +func (t *pgxTransactionAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return t.tx.Query(ctx, sql, args...) +} + +func (t *pgxTransactionAdapter) Exec(ctx context.Context, sql string, args ...any) error { + _, err := t.tx.Exec(ctx, sql, args...) + return err +} + +func (t *pgxTransactionAdapter) Commit(ctx context.Context) error { + return t.tx.Commit(ctx) +} + +func (t *pgxTransactionAdapter) Rollback(ctx context.Context) error { + return t.tx.Rollback(ctx) +} + +var _ Transaction = (*pgxTransactionAdapter)(nil) diff --git a/backend/internal/postgres/password_login.go b/backend/internal/postgres/password_login.go new file mode 100644 index 0000000..1c4d443 --- /dev/null +++ b/backend/internal/postgres/password_login.go @@ -0,0 +1,220 @@ +package postgres + +import ( + "context" + "crypto/subtle" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "golang.org/x/crypto/scrypt" +) + +const SelectPasswordLoginAccountSQL = `SELECT + id, + phone, + display_name, + role, + organization_id, + status, + password_hash, + password_salt, + failed_login_count, + locked_until, + session_version +FROM public.platform_users +WHERE phone = $1::text +FOR UPDATE` + +const SelectPasswordLoginOrganizationSQL = `SELECT + id, + name, + status +FROM public.platform_organizations +WHERE id = $1::text` + +const RecordFailedPasswordLoginSQL = `UPDATE public.platform_users +SET failed_login_count = $2, + locked_until = $3, + updated_at = $4 +WHERE id = $1::text` + +const RecordSuccessfulPasswordLoginSQL = `UPDATE public.platform_users +SET failed_login_count = 0, + locked_until = NULL, + last_login_at = $2, + updated_at = $2 +WHERE id = $1::text` + +const ( + passwordLoginMaxFailures = 5 + passwordLoginLockTime = 15 * time.Minute +) + +// AttemptPasswordLogin performs each credential attempt under the account +// row's PostgreSQL lock. Expected authentication denials are committed so a +// failed-password transition cannot be accidentally rolled back by a caller. +func (db *Database) AttemptPasswordLogin(ctx context.Context, phone, password string, now time.Time) (identity.LoginAccount, error) { + if db.config.Backend != BackendPostgres || db.transactions == nil { + return identity.LoginAccount{}, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend) + } + tx, err := db.transactions.Begin(ctx) + if err != nil { + return identity.LoginAccount{}, fmt.Errorf("begin password login transaction: %w", err) + } + finished := false + defer func() { + if !finished { + _ = tx.Rollback(ctx) + } + }() + + account, passwordHash, passwordSalt, failedCount, lockedUntil, found, err := loadPasswordLoginAccount(ctx, tx, phone) + if err != nil { + return identity.LoginAccount{}, err + } + if !found { + return commitPasswordLoginDenial(ctx, tx, &finished, identity.LoginFailureInvalidCredentials) + } + if account.Status != "active" { + return commitPasswordLoginDenial(ctx, tx, &finished, identity.LoginFailureAccountDisabled) + } + result := identity.LoginAccount{Account: account} + switch account.Role { + case "super_admin": + if account.OrganizationID != "" { + organization, found, err := loadPasswordLoginOrganization(ctx, tx, account.OrganizationID) + if err != nil { + return identity.LoginAccount{}, err + } + if found { + result.Organization = &organization + } + } + case "user", "organization_admin": + if account.OrganizationID == "" { + return commitPasswordLoginDenial(ctx, tx, &finished, identity.LoginFailureOrganizationRequired) + } + organization, found, err := loadPasswordLoginOrganization(ctx, tx, account.OrganizationID) + if err != nil { + return identity.LoginAccount{}, err + } + if !found || organization.ID != account.OrganizationID || organization.Status != "active" { + return commitPasswordLoginDenial(ctx, tx, &finished, identity.LoginFailureOrganizationNotActive) + } + result.Organization = &organization + default: + return commitPasswordLoginDenial(ctx, tx, &finished, identity.LoginFailureInvalidRole) + } + if lockedUntil.Valid && lockedUntil.Time.After(now) { + return commitPasswordLoginDenial(ctx, tx, &finished, identity.LoginFailureAccountLocked) + } + + validPassword, err := verifyNodeScryptPassword(password, passwordHash, passwordSalt) + if err != nil { + return identity.LoginAccount{}, fmt.Errorf("verify password: %w", err) + } + if !validPassword { + failedCount++ + var nextLockedUntil any + reason := identity.LoginFailureInvalidCredentials + if failedCount >= passwordLoginMaxFailures { + failedCount = 0 + nextLockedUntil = now.Add(passwordLoginLockTime) + reason = identity.LoginFailureAccountLocked + } + if err := tx.Exec(ctx, RecordFailedPasswordLoginSQL, account.ID, failedCount, nextLockedUntil, now); err != nil { + return identity.LoginAccount{}, fmt.Errorf("record failed password login: %w", err) + } + return commitPasswordLoginDenial(ctx, tx, &finished, reason) + } + + if err := tx.Exec(ctx, RecordSuccessfulPasswordLoginSQL, account.ID, now); err != nil { + return identity.LoginAccount{}, fmt.Errorf("record successful password login: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return identity.LoginAccount{}, fmt.Errorf("commit password login transaction: %w", err) + } + finished = true + return result, nil +} + +func loadPasswordLoginAccount(ctx context.Context, tx Transaction, phone string) (identity.AccountSnapshot, string, string, int, sql.NullTime, bool, error) { + rows, err := tx.Query(ctx, SelectPasswordLoginAccountSQL, phone) + if err != nil { + return identity.AccountSnapshot{}, "", "", 0, sql.NullTime{}, false, fmt.Errorf("query password login account: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return identity.AccountSnapshot{}, "", "", 0, sql.NullTime{}, false, fmt.Errorf("read password login account: %w", err) + } + return identity.AccountSnapshot{}, "", "", 0, sql.NullTime{}, false, nil + } + + var account identity.AccountSnapshot + var organizationID sql.NullString + var passwordHash, passwordSalt string + var failedCount int + var lockedUntil sql.NullTime + if err := rows.Scan( + &account.ID, &account.Phone, &account.DisplayName, &account.Role, + &organizationID, &account.Status, &passwordHash, &passwordSalt, + &failedCount, &lockedUntil, &account.SessionVersion, + ); err != nil { + return identity.AccountSnapshot{}, "", "", 0, sql.NullTime{}, false, fmt.Errorf("scan password login account: %w", err) + } + if err := rows.Err(); err != nil { + return identity.AccountSnapshot{}, "", "", 0, sql.NullTime{}, false, fmt.Errorf("read password login account: %w", err) + } + if organizationID.Valid { + account.OrganizationID = organizationID.String + } + return account, passwordHash, passwordSalt, failedCount, lockedUntil, true, nil +} + +func loadPasswordLoginOrganization(ctx context.Context, tx Transaction, organizationID string) (identity.OrganizationSnapshot, bool, error) { + rows, err := tx.Query(ctx, SelectPasswordLoginOrganizationSQL, organizationID) + if err != nil { + return identity.OrganizationSnapshot{}, false, fmt.Errorf("query password login organization: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return identity.OrganizationSnapshot{}, false, fmt.Errorf("read password login organization: %w", err) + } + return identity.OrganizationSnapshot{}, false, nil + } + var organization identity.OrganizationSnapshot + if err := rows.Scan(&organization.ID, &organization.Name, &organization.Status); err != nil { + return identity.OrganizationSnapshot{}, false, fmt.Errorf("scan password login organization: %w", err) + } + if err := rows.Err(); err != nil { + return identity.OrganizationSnapshot{}, false, fmt.Errorf("read password login organization: %w", err) + } + return organization, true, nil +} + +func commitPasswordLoginDenial(ctx context.Context, tx Transaction, finished *bool, reason identity.PasswordLoginFailure) (identity.LoginAccount, error) { + if err := tx.Commit(ctx); err != nil { + return identity.LoginAccount{}, fmt.Errorf("commit password login denial: %w", err) + } + *finished = true + return identity.LoginAccount{}, identity.NewPasswordLoginError(reason) +} + +func verifyNodeScryptPassword(password, encodedHash, salt string) (bool, error) { + derived, err := scrypt.Key([]byte(password), []byte(salt), 16384, 8, 1, 64) + if err != nil { + return false, err + } + expected, decodeErr := hex.DecodeString(encodedHash) + if decodeErr != nil || len(expected) != len(derived) { + expected = make([]byte, len(derived)) + } + return subtle.ConstantTimeCompare(derived, expected) == 1 && decodeErr == nil, nil +} + +var _ identity.CredentialAuthenticator = (*Database)(nil) diff --git a/backend/internal/postgres/password_login_test.go b/backend/internal/postgres/password_login_test.go new file mode 100644 index 0000000..f83a7f2 --- /dev/null +++ b/backend/internal/postgres/password_login_test.go @@ -0,0 +1,370 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/hex" + "errors" + "reflect" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "golang.org/x/crypto/scrypt" +) + +func TestAttemptPasswordLoginSuccessUsesOneLockedTransaction(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "001122aabbccddeeff") + tx := &loginTransaction{queries: []loginQueryResult{ + {rows: loginRows([]any{"user-1", "13800138000", "Name", "user", "org-1", "active", hash, "001122aabbccddeeff", 2, nil, 9})}, + {rows: loginRows([]any{"org-1", "Acme", "active"})}, + }} + db := loginDatabase(tx) + + got, err := db.AttemptPasswordLogin(context.Background(), "13800138000", "secret", now) + if err != nil { + t.Fatalf("AttemptPasswordLogin() error = %v", err) + } + if got.Account.ID != "user-1" || got.Account.SessionVersion != 9 || got.Organization == nil || got.Organization.ID != "org-1" { + t.Fatalf("AttemptPasswordLogin() = %#v", got) + } + wantEvents := []string{"BEGIN", "QUERY user", "QUERY organization", "EXEC success", "COMMIT"} + if !reflect.DeepEqual(tx.events, wantEvents) { + t.Fatalf("events = %#v, want %#v", tx.events, wantEvents) + } + if tx.queriesSeen[0].sql != SelectPasswordLoginAccountSQL || !reflect.DeepEqual(tx.queriesSeen[0].args, []any{"13800138000"}) { + t.Fatalf("account query = %#v", tx.queriesSeen[0]) + } + if tx.queriesSeen[1].sql != SelectPasswordLoginOrganizationSQL || !reflect.DeepEqual(tx.queriesSeen[1].args, []any{"org-1"}) { + t.Fatalf("organization query = %#v", tx.queriesSeen[1]) + } + if len(tx.execs) != 1 || tx.execs[0].sql != RecordSuccessfulPasswordLoginSQL || !reflect.DeepEqual(tx.execs[0].args, []any{"user-1", now}) { + t.Fatalf("success update = %#v", tx.execs) + } +} + +func TestAttemptPasswordLoginPreservesBoundSuperAdministratorOrganizationProfile(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "salt") + for _, status := range []string{"active", "disabled"} { + t.Run(status, func(t *testing.T) { + tx := &loginTransaction{queries: []loginQueryResult{ + {rows: loginRows([]any{"super-1", "13800138000", "Super", "super_admin", "org-1", "active", hash, "salt", 0, nil, 3})}, + {rows: loginRows([]any{"org-1", "Bound Organization", status})}, + }} + got, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "13800138000", "secret", now) + if err != nil { + t.Fatalf("AttemptPasswordLogin() error = %v", err) + } + if got.Organization == nil || got.Organization.ID != "org-1" || got.Organization.Name != "Bound Organization" || got.Organization.Status != status { + t.Fatalf("organization = %#v", got.Organization) + } + wantEvents := []string{"BEGIN", "QUERY user", "QUERY organization", "EXEC success", "COMMIT"} + if !reflect.DeepEqual(tx.events, wantEvents) { + t.Fatalf("events = %#v, want %#v", tx.events, wantEvents) + } + }) + } +} + +func TestAttemptPasswordLoginAllowsBoundSuperAdministratorWithMissingOrganization(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "salt") + tx := &loginTransaction{queries: []loginQueryResult{ + {rows: loginRows([]any{"super-1", "13800138000", "Super", "super_admin", "deleted-org", "active", hash, "salt", 0, nil, 3})}, + {rows: &loginFakeRows{}}, + }} + got, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "13800138000", "secret", now) + if err != nil { + t.Fatalf("AttemptPasswordLogin() error = %v", err) + } + if got.Organization != nil || got.Account.OrganizationID != "deleted-org" { + t.Fatalf("result = %#v", got) + } + if len(tx.execs) != 1 || tx.execs[0].sql != RecordSuccessfulPasswordLoginSQL || tx.commits != 1 { + t.Fatalf("execs=%#v commits=%d", tx.execs, tx.commits) + } +} + +func TestAttemptPasswordLoginCommitsExpectedDenials(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "salt") + tests := []struct { + name string + row []any + password string + wantReason identity.PasswordLoginFailure + wantExec []any + }{ + {name: "missing", wantReason: identity.LoginFailureInvalidCredentials}, + {name: "disabled", row: []any{"u", "p", "N", "user", "org", "disabled", hash, "salt", 0, nil, 1}, password: "secret", wantReason: identity.LoginFailureAccountDisabled}, + {name: "locked", row: []any{"u", "p", "N", "super_admin", nil, "active", hash, "salt", 0, now.Add(time.Minute), 1}, password: "secret", wantReason: identity.LoginFailureAccountLocked}, + {name: "bad password", row: []any{"u", "p", "N", "super_admin", nil, "active", hash, "salt", 3, nil, 1}, password: "wrong", wantReason: identity.LoginFailureInvalidCredentials, wantExec: []any{"u", 4, nil, now}}, + {name: "fifth failure locks", row: []any{"u", "p", "N", "super_admin", nil, "active", hash, "salt", 4, nil, 1}, password: "wrong", wantReason: identity.LoginFailureAccountLocked, wantExec: []any{"u", 0, now.Add(15 * time.Minute), now}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rows := &loginFakeRows{} + if test.row != nil { + rows.rows = [][]any{test.row} + } + tx := &loginTransaction{queries: []loginQueryResult{{rows: rows}}} + _, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", test.password, now) + var denial *identity.PasswordLoginError + if !errors.As(err, &denial) || denial.Reason != test.wantReason { + t.Fatalf("error = %v, want reason %s", err, test.wantReason) + } + if tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("commits=%d rollbacks=%d, want 1/0", tx.commits, tx.rollbacks) + } + if test.wantExec == nil { + if len(tx.execs) != 0 { + t.Fatalf("unexpected execs %#v", tx.execs) + } + } else if len(tx.execs) != 1 || tx.execs[0].sql != RecordFailedPasswordLoginSQL || !reflect.DeepEqual(tx.execs[0].args, test.wantExec) { + t.Fatalf("failure update = %#v, want args %#v", tx.execs, test.wantExec) + } + }) + } +} + +func TestAttemptPasswordLoginRejectsInactiveOrganizationBeforePasswordState(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "salt") + for _, test := range []struct { + name string + password string + lockedUntil any + }{ + {name: "wrong password", password: "wrong"}, + {name: "locked account", password: "secret", lockedUntil: now.Add(time.Minute)}, + } { + t.Run(test.name, func(t *testing.T) { + tx := &loginTransaction{queries: []loginQueryResult{ + {rows: loginRows([]any{"u", "p", "N", "user", "org", "active", hash, "salt", 4, test.lockedUntil, 1})}, + {rows: loginRows([]any{"org", "Acme", "disabled"})}, + }} + _, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", test.password, now) + assertCommittedDenialWithoutUpdate(t, tx, err, identity.LoginFailureOrganizationNotActive) + }) + } +} + +func TestAttemptPasswordLoginRejectsInvalidRoleAndMissingOrganizationBeforePasswordState(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "salt") + for _, test := range []struct { + name string + role string + orgID any + wantReason identity.PasswordLoginFailure + }{ + {name: "invalid role", role: "root", orgID: "org", wantReason: identity.LoginFailureInvalidRole}, + {name: "organization required", role: "user", orgID: nil, wantReason: identity.LoginFailureOrganizationRequired}, + } { + t.Run(test.name, func(t *testing.T) { + tx := &loginTransaction{queries: []loginQueryResult{{rows: loginRows([]any{ + "u", "p", "N", test.role, test.orgID, "active", hash, "salt", 4, now.Add(time.Minute), 1, + })}}} + _, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", "wrong", now) + assertCommittedDenialWithoutUpdate(t, tx, err, test.wantReason) + if len(tx.queriesSeen) != 1 { + t.Fatalf("queries = %#v, want account query only", tx.queriesSeen) + } + }) + } +} + +func assertCommittedDenialWithoutUpdate(t *testing.T, tx *loginTransaction, err error, wantReason identity.PasswordLoginFailure) { + t.Helper() + var denial *identity.PasswordLoginError + if !errors.As(err, &denial) || denial.Reason != wantReason { + t.Fatalf("error = %v, want reason %s", err, wantReason) + } + if len(tx.execs) != 0 || tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("execs=%#v commits=%d rollbacks=%d, want no update and commit", tx.execs, tx.commits, tx.rollbacks) + } +} + +func TestAttemptPasswordLoginRollsBackInfrastructureFailures(t *testing.T) { + now := time.Now() + queryErr := errors.New("query failed") + tx := &loginTransaction{queries: []loginQueryResult{{err: queryErr}}} + _, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", "secret", now) + if !errors.Is(err, queryErr) || tx.commits != 0 || tx.rollbacks != 1 { + t.Fatalf("error=%v commits=%d rollbacks=%d", err, tx.commits, tx.rollbacks) + } + + commitErr := errors.New("commit failed") + tx = &loginTransaction{queries: []loginQueryResult{{rows: &loginFakeRows{}}}, commitErr: commitErr} + _, err = loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", "secret", now) + if !errors.Is(err, commitErr) || tx.commits != 1 { + t.Fatalf("commit error=%v commits=%d", err, tx.commits) + } +} + +func TestAttemptPasswordLoginFailsClosedWithoutTransactionSupport(t *testing.T) { + tests := []struct { + name string + config Config + query Querier + }{ + {name: "local", config: Config{Backend: BackendLocal}, query: &identityQuerier{}}, + {name: "postgres without beginner", config: Config{Backend: BackendPostgres}, query: &identityQuerier{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewDatabase(test.config, test.query).AttemptPasswordLogin(context.Background(), "p", "secret", time.Now()) + if err == nil { + t.Fatal("AttemptPasswordLogin() error = nil") + } + }) + } +} + +func TestAttemptPasswordLoginMalformedStoredHashIsAnExpectedCredentialDenial(t *testing.T) { + tx := &loginTransaction{queries: []loginQueryResult{{rows: loginRows([]any{ + "u", "p", "N", "super_admin", nil, "active", "not-hex", "salt", 0, nil, 1, + })}}} + _, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", "secret", time.Now()) + var denial *identity.PasswordLoginError + if !errors.As(err, &denial) || denial.Reason != identity.LoginFailureInvalidCredentials || tx.commits != 1 || len(tx.execs) != 1 { + t.Fatalf("error=%v commits=%d execs=%#v", err, tx.commits, tx.execs) + } +} + +func TestAttemptPasswordLoginSerializesFiveFailureTransitions(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + hash := nodeCompatibleHash(t, "secret", "salt") + for attempt := 1; attempt <= 5; attempt++ { + tx := &loginTransaction{queries: []loginQueryResult{{rows: loginRows([]any{"u", "p", "N", "super_admin", nil, "active", hash, "salt", attempt - 1, nil, 1})}}} + _, err := loginDatabase(tx).AttemptPasswordLogin(context.Background(), "p", "wrong", now) + var denial *identity.PasswordLoginError + if !errors.As(err, &denial) { + t.Fatalf("attempt %d error=%v", attempt, err) + } + wantCount := attempt + wantReason := identity.LoginFailureInvalidCredentials + if attempt == 5 { + wantCount, wantReason = 0, identity.LoginFailureAccountLocked + } + if denial.Reason != wantReason || tx.execs[0].args[1] != wantCount || tx.commits != 1 { + t.Fatalf("attempt %d reason=%s args=%#v commits=%d", attempt, denial.Reason, tx.execs[0].args, tx.commits) + } + } +} + +func nodeCompatibleHash(t *testing.T, password, salt string) string { + t.Helper() + derived, err := scrypt.Key([]byte(password), []byte(salt), 16384, 8, 1, 64) + if err != nil { + t.Fatal(err) + } + return hex.EncodeToString(derived) +} + +func loginDatabase(tx *loginTransaction) *Database { + return NewDatabase(Config{Backend: BackendPostgres}, &loginPool{tx: tx}) +} + +type loginPool struct{ tx *loginTransaction } + +func (p *loginPool) Query(context.Context, string, ...any) (Rows, error) { + return nil, errors.New("query outside transaction") +} +func (p *loginPool) Begin(context.Context) (Transaction, error) { + p.tx.events = append(p.tx.events, "BEGIN") + return p.tx, nil +} + +type loginCall struct { + sql string + args []any +} +type loginQueryResult struct { + rows Rows + err error +} +type loginTransaction struct { + queries []loginQueryResult + queriesSeen []loginCall + execs []loginCall + events []string + commitErr error + commits, rollbacks int +} + +func (t *loginTransaction) Query(_ context.Context, query string, args ...any) (Rows, error) { + t.queriesSeen = append(t.queriesSeen, loginCall{query, args}) + if query == SelectPasswordLoginAccountSQL { + t.events = append(t.events, "QUERY user") + } else { + t.events = append(t.events, "QUERY organization") + } + result := t.queries[0] + t.queries = t.queries[1:] + return result.rows, result.err +} +func (t *loginTransaction) Exec(_ context.Context, query string, args ...any) error { + t.execs = append(t.execs, loginCall{query, args}) + if query == RecordSuccessfulPasswordLoginSQL { + t.events = append(t.events, "EXEC success") + } else { + t.events = append(t.events, "EXEC failure") + } + return nil +} +func (t *loginTransaction) Commit(context.Context) error { + t.commits++ + t.events = append(t.events, "COMMIT") + return t.commitErr +} +func (t *loginTransaction) Rollback(context.Context) error { + t.rollbacks++ + t.events = append(t.events, "ROLLBACK") + return nil +} + +func loginRows(row []any) *loginFakeRows { return &loginFakeRows{rows: [][]any{row}} } + +type loginFakeRows struct { + rows [][]any + index int + err error +} + +func (r *loginFakeRows) Close() {} +func (r *loginFakeRows) Err() error { return r.err } +func (r *loginFakeRows) Next() bool { return r.index < len(r.rows) } +func (r *loginFakeRows) Scan(dest ...any) error { + if r.index >= len(r.rows) { + return errors.New("scan past end") + } + row := r.rows[r.index] + r.index++ + if len(row) != len(dest) { + return errors.New("scan arity mismatch") + } + for i, value := range row { + switch target := dest[i].(type) { + case *string: + *target = value.(string) + case *int: + *target = value.(int) + case *sql.NullString: + if value != nil { + *target = sql.NullString{String: value.(string), Valid: true} + } + case *sql.NullTime: + if value != nil { + *target = sql.NullTime{Time: value.(time.Time), Valid: true} + } + default: + return errors.New("unsupported scan target") + } + } + return nil +} + +var _ identity.CredentialAuthenticator = (*Database)(nil) diff --git a/contracts/auth/logout-v1.json b/contracts/auth/logout-v1.json new file mode 100644 index 0000000..ed9843f --- /dev/null +++ b/contracts/auth/logout-v1.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "path": "/api/auth/logout", + "methods": ["GET", "POST"], + "status": 307, + "location": "https://app.example.test/auth/login?loggedOut=1", + "requiresAuthentication": false, + "cookieFixture": "session-cookie-v1.json", + "duplicateBaseCookieWrite": false +} diff --git a/contracts/auth/password-login-v1.json b/contracts/auth/password-login-v1.json new file mode 100644 index 0000000..62d31f2 --- /dev/null +++ b/contracts/auth/password-login-v1.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "path": "/api/auth/password", + "method": "POST", + "localSessionTtlSeconds": 86400, + "inputCases": [ + { + "name": "phone and password are trimmed and user authMode input is ignored", + "body": { + "phone": " 13800138000 ", + "password": " TestPass123 ", + "authMode": "admin", + "next": " /assets " + }, + "expectedRedirect": "/assets" + }, + { + "name": "username is an accepted phone alias and unknown fields are ignored", + "body": { + "username": " 13800138000 ", + "password": "TestPass123", + "authMode": "something-else", + "ignored": true, + "next": "/create?mode=image#prompt" + }, + "expectedRedirect": "/create?mode=image#prompt" + } + ], + "safeNextCases": [ + { "input": null, "expected": "/create" }, + { "input": "", "expected": "/create" }, + { "input": "assets", "expected": "/create" }, + { "input": "//evil.example/path", "expected": "/create" }, + { "input": "https://evil.example/path", "expected": "/create" }, + { "input": "/\\evil.example/path", "expected": "/create" }, + { "input": "/api/auth/logout", "expected": "/create" }, + { "input": "/foo/../api/auth/logout", "expected": "/create" }, + { "input": "/foo/%2e%2e/api/auth/logout", "expected": "/create" }, + { "input": "/auth/login?next=/assets", "expected": "/create" }, + { "input": "/auth/admin-login", "expected": "/create" }, + { "input": "/assets/../usage?tab=mine#x", "expected": "/usage?tab=mine#x" }, + { "input": "/assets/.", "expected": "/assets/" }, + { "input": "/assets/%41", "expected": "/assets/%41" }, + { "input": "/assets/%2F/item", "expected": "/assets/%2F/item" }, + { "input": "/assets?tab=mine#recent", "expected": "/assets?tab=mine#recent" } + ], + "success": { + "topLevelKeys": ["ok", "redirectTo", "user", "authMode"], + "publicUserKeys": [ + "id", + "subject", + "username", + "phone", + "displayName", + "clientId", + "organizationId", + "organizationName", + "role", + "status", + "authorities", + "scope" + ], + "forbiddenSerializedKeys": [ + "password", + "passwordHash", + "passwordSalt", + "failedLoginCount", + "lockedUntil", + "lastLoginAt", + "sessionVersion", + "issuedAt", + "expiresAt", + "accessToken", + "tokenType" + ] + }, + "errors": { + "invalidInput": { + "status": 400, + "body": { "error": "手机号和密码不能为空。" } + }, + "invalidCredentials": { + "status": 401, + "body": { "error": "手机号或密码错误。" } + }, + "disabledAccount": { + "status": 403, + "body": { "error": "账号已停用,请联系管理员。" } + }, + "disabledOrganization": { + "status": 403, + "body": { "error": "所属组织已停用,请联系管理员。" } + }, + "lockedAccount": { + "status": 423, + "body": { "error": "登录失败次数过多,请 15 分钟后再试。" } + }, + "rateLimited": { + "status": 429, + "body": { "error": "请求过于频繁,请稍后再试。" } + }, + "unconfigured": { + "status": 503, + "body": { "error": "账号认证配置不完整:ZHINIAN_AUTH_SESSION_SECRET" } + } + }, + "rateLimit": { + "attemptsPerIp": 30, + "windowSeconds": 900 + }, + "cookieFixture": "session-cookie-v1.json" +} diff --git a/tests/auth-logout-contract.test.ts b/tests/auth-logout-contract.test.ts new file mode 100644 index 0000000..cb223de --- /dev/null +++ b/tests/auth-logout-contract.test.ts @@ -0,0 +1,84 @@ +import { readFile } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as logoutRoute from "@/app/api/auth/logout/route"; + +type LogoutFixture = { + version: 1; + path: string; + methods: string[]; + status: number; + location: string; + requiresAuthentication: boolean; + cookieFixture: string; + duplicateBaseCookieWrite: boolean; +}; + +type SessionCookieFixture = { + cookie: { + chunkNames: string[]; + attributes: { httpOnly: boolean; sameSite: string; path: string }; + clear: { value: string; maxAgeSeconds: number }; + }; +}; + +const fixtureUrl = new URL("../contracts/auth/logout-v1.json", import.meta.url); + +async function loadFixture(): Promise { + return JSON.parse(await readFile(fixtureUrl, "utf8")) as LogoutFixture; +} + +async function loadCookieFixture(relativePath: string): Promise { + return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture; +} + +function setCookieLines(response: Response): string[] { + const headers = response.headers as Headers & { getSetCookie?: () => string[] }; + return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""]; +} + +afterEach(() => vi.unstubAllEnvs()); + +describe("logout HTTP v1 cross-language contract", () => { + it("allows anonymous GET and POST and returns the same 307 redirect", async () => { + const fixture = await loadFixture(); + expect({ version: fixture.version, path: fixture.path, methods: fixture.methods }).toEqual({ + version: 1, + path: "/api/auth/logout", + methods: ["GET", "POST"] + }); + expect(Object.keys(logoutRoute).sort()).toEqual(["GET", "POST", "runtime"]); + expect(fixture.requiresAuthentication).toBe(false); + + for (const method of fixture.methods) { + const response = await logoutRoute[method as "GET" | "POST"]( + new Request("https://app.example.test/api/auth/logout", { method }) + ); + expect(response.status, method).toBe(fixture.status); + expect(response.headers.get("location"), method).toBe(fixture.location); + } + }); + + it("clears all 20 legacy chunk names and preserves the externally visible duplicate base write", async () => { + const fixture = await loadFixture(); + const cookieFixture = await loadCookieFixture(fixture.cookieFixture); + vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "true"); + const response = await logoutRoute.POST(new Request("http://127.0.0.1/api/auth/logout", { method: "POST" })); + const lines = setCookieLines(response); + const names = lines.map((line) => line.slice(0, line.indexOf("="))); + + expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length + (fixture.duplicateBaseCookieWrite ? 1 : 0)); + expect(names).toEqual([ + ...cookieFixture.cookie.chunkNames, + ...(fixture.duplicateBaseCookieWrite ? [cookieFixture.cookie.chunkNames[0]] : []) + ]); + for (const line of lines) { + expect(line).toContain("HttpOnly"); + expect(line).toContain("Path=/"); + expect(line).toContain("SameSite=lax"); + expect(line).toContain("Secure"); + expect(line).toContain(`Max-Age=${cookieFixture.cookie.clear.maxAgeSeconds}`); + } + }); +}); diff --git a/tests/auth-password-login-contract.test.ts b/tests/auth-password-login-contract.test.ts new file mode 100644 index 0000000..200068d --- /dev/null +++ b/tests/auth-password-login-contract.test.ts @@ -0,0 +1,260 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { safeNextPath } from "@/lib/auth/config"; +import { parseSessionCookieValue } from "@/lib/auth/session"; +import { + createPlatformOrganization, + createPlatformUser, + updatePlatformOrganization, + updatePlatformUser +} from "@/lib/server/account-store"; +import { resetLocalAuthRateLimitForTests } from "@/lib/server/auth/local"; +import type { PlatformOrganization, PlatformUserRecord } from "@/lib/types"; +import * as passwordRoute from "@/app/api/auth/password/route"; + +type PasswordLoginFixture = { + version: 1; + path: string; + method: "POST"; + localSessionTtlSeconds: number; + inputCases: Array<{ + name: string; + body: Record; + expectedRedirect: string; + }>; + safeNextCases: Array<{ input: string | null; expected: string }>; + success: { + topLevelKeys: string[]; + publicUserKeys: string[]; + forbiddenSerializedKeys: string[]; + }; + errors: Record<"invalidInput" | "invalidCredentials" | "disabledAccount" | "disabledOrganization" | "lockedAccount" | "rateLimited" | "unconfigured", { + status: number; + body: { error: string }; + }>; + rateLimit: { attemptsPerIp: number; windowSeconds: number }; + cookieFixture: string; +}; + +type SessionCookieFixture = { + cookie: { + chunkNames: string[]; + attributes: { httpOnly: boolean; sameSite: string; path: string }; + clear: { maxAgeSeconds: number }; + }; +}; + +const fixtureUrl = new URL("../contracts/auth/password-login-v1.json", import.meta.url); +const authEnvironmentKeys = [ + "NODE_ENV", + "ZHINIAN_DATA_DIR", + "ZHINIAN_DATA_BACKEND", + "ZHINIAN_AUTH_REQUIRED", + "ZHINIAN_AUTH_DISABLED", + "ZHINIAN_AUTH_SESSION_SECRET", + "AUTH_SESSION_SECRET", + "NEXTAUTH_SECRET", + "ZHINIAN_AUTH_COOKIE_SECURE", + "NEXT_PUBLIC_APP_URL", + "ZHINIAN_PUBLIC_BASE_URL" +] as const; +const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]])); +const sessionSecret = "password-contract-session-secret-with-enough-entropy"; +let runtimeDir = ""; +let organization: PlatformOrganization; +let user: PlatformUserRecord; + +async function loadFixture(): Promise { + return JSON.parse(await readFile(fixtureUrl, "utf8")) as PasswordLoginFixture; +} + +async function loadCookieFixture(relativePath: string): Promise { + return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture; +} + +function login(body: unknown, ip = "192.0.2.10", url = "http://127.0.0.1/api/auth/password") { + return passwordRoute.POST(new Request(url, { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": ip }, + body: typeof body === "string" ? body : JSON.stringify(body) + })); +} + +function setCookieLines(response: Response): string[] { + const headers = response.headers as Headers & { getSetCookie?: () => string[] }; + return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""]; +} + +function unsignedCookiePayload(cookieValue: string): Record { + const [payload] = cookieValue.split("."); + return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64url").toString("utf8")) as Record; +} + +beforeEach(async () => { + runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-password-contract-")); + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("ZHINIAN_DATA_DIR", runtimeDir); + vi.stubEnv("ZHINIAN_DATA_BACKEND", "local"); + vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1"); + vi.stubEnv("ZHINIAN_AUTH_DISABLED", ""); + vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", sessionSecret); + vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "false"); + resetLocalAuthRateLimitForTests(); + organization = await createPlatformOrganization("契约测试组织"); + user = await createPlatformUser({ + phone: "13800138000", + displayName: "契约测试用户", + password: "TestPass123", + role: "user", + organizationId: organization.id + }); +}); + +afterEach(async () => { + resetLocalAuthRateLimitForTests(); + vi.unstubAllEnvs(); + for (const key of authEnvironmentKeys) { + const original = originalEnvironment.get(key); + if (original === undefined) Reflect.deleteProperty(process.env, key); + else Reflect.set(process.env, key, original); + } + await rm(runtimeDir, { recursive: true, force: true }); +}); + +describe("password login HTTP v1 cross-language contract", () => { + it("freezes route identity, input aliases, trimming, ignored authMode, and safe redirects", async () => { + const fixture = await loadFixture(); + expect({ version: fixture.version, path: fixture.path, method: fixture.method }).toEqual({ + version: 1, + path: "/api/auth/password", + method: "POST" + }); + expect(Object.keys(passwordRoute).sort()).toEqual(["POST", "dynamic", "runtime"]); + expect(passwordRoute.runtime).toBe("nodejs"); + expect(passwordRoute.dynamic).toBe("force-dynamic"); + + for (const testCase of fixture.safeNextCases) { + expect(safeNextPath(testCase.input), testCase.input ?? "null").toBe(testCase.expected); + } + + for (const [index, testCase] of fixture.inputCases.entries()) { + const response = await login(testCase.body, `192.0.2.${20 + index}`); + expect(response.status, testCase.name).toBe(200); + const body = await response.json(); + expect(body.redirectTo, testCase.name).toBe(testCase.expectedRedirect); + expect(body.authMode, testCase.name).toBe("user"); + } + }); + + it("returns the exact public body and a parseable one-day session without sensitive fields", async () => { + const fixture = await loadFixture(); + const response = await login({ phone: user.phone, password: "TestPass123", next: "/assets?tab=mine#recent" }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + ok: true, + redirectTo: "/assets?tab=mine#recent", + user: { + id: user.id, + subject: user.id, + username: user.phone, + phone: user.phone, + displayName: user.displayName, + clientId: "platform", + organizationId: organization.id, + organizationName: organization.name, + role: "user", + status: "active", + authorities: ["ROLE_USER"], + scope: [] + }, + authMode: "user" + }); + expect(Object.keys(body).sort()).toEqual([...fixture.success.topLevelKeys].sort()); + expect(Object.keys(body.user).sort()).toEqual([...fixture.success.publicUserKeys].sort()); + for (const key of fixture.success.forbiddenSerializedKeys) { + expect(JSON.stringify(body)).not.toContain(`"${key}"`); + } + + const cookieValue = response.cookies.get("zhinian_session")?.value; + const session = await parseSessionCookieValue(cookieValue, sessionSecret, 0); + expect(session).not.toBeNull(); + expect(session?.expiresAt! - session?.issuedAt!).toBe(fixture.localSessionTtlSeconds); + expect(session).toMatchObject({ + version: 1, + authMode: "user", + sessionVersion: user.sessionVersion, + user: body.user + }); + const rawSession = unsignedCookiePayload(cookieValue!); + expect(rawSession).not.toHaveProperty("accessToken"); + expect(rawSession).not.toHaveProperty("tokenType"); + }); + + it("writes the shared Cookie name set and legacy attributes, including stale-chunk clears", async () => { + const fixture = await loadFixture(); + const cookieFixture = await loadCookieFixture(fixture.cookieFixture); + const response = await login({ phone: user.phone, password: "TestPass123" }); + const lines = setCookieLines(response); + + expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length); + expect(lines.map((line) => line.slice(0, line.indexOf("=")))).toEqual(cookieFixture.cookie.chunkNames); + expect(lines[0]).toContain("HttpOnly"); + expect(lines[0]).toContain("Path=/"); + expect(lines[0]).toContain("SameSite=lax"); + expect(lines[0]).not.toContain("Secure"); + expect(lines[0]).toContain("Expires="); + for (const line of lines.slice(1)) { + expect(line).toContain("Max-Age=0"); + expect(line).not.toContain("Expires="); + } + }); + + it("freezes 400, 401, 403, 423, 429, and 503 public error semantics", async () => { + const { errors, rateLimit } = await loadFixture(); + + const invalidInput = await login("not-json", "192.0.2.30"); + expect({ status: invalidInput.status, body: await invalidInput.json() }).toEqual(errors.invalidInput); + + const invalidCredentials = await login({ username: " 19900000000 ", password: "wrong" }, "192.0.2.31"); + expect({ status: invalidCredentials.status, body: await invalidCredentials.json() }).toEqual(errors.invalidCredentials); + + await updatePlatformUser(user.id, { status: "disabled" }); + const disabled = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.32"); + expect({ status: disabled.status, body: await disabled.json() }).toEqual(errors.disabledAccount); + await updatePlatformUser(user.id, { status: "active" }); + + let locked: Response | undefined; + for (let attempt = 0; attempt < 5; attempt += 1) { + locked = await login({ phone: user.phone, password: "wrong" }, `192.0.2.${40 + attempt}`); + } + expect({ status: locked?.status, body: await locked?.json() }).toEqual(errors.lockedAccount); + + for (let attempt = 0; attempt < rateLimit.attemptsPerIp; attempt += 1) { + const response = await login({ phone: "19900000000", password: "wrong" }, "192.0.2.50"); + expect(response.status, `allowed IP attempt ${attempt + 1}`).toBe(401); + } + const rateLimited = await login({ phone: "19900000000", password: "wrong" }, "192.0.2.50"); + expect({ status: rateLimited.status, body: await rateLimited.json() }).toEqual(errors.rateLimited); + + vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", ""); + vi.stubEnv("AUTH_SESSION_SECRET", ""); + vi.stubEnv("NEXTAUTH_SECRET", ""); + const unconfigured = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.60"); + expect({ status: unconfigured.status, body: await unconfigured.json() }).toEqual(errors.unconfigured); + + expect(rateLimit.windowSeconds).toBe(15 * 60); + }); + + it("rejects a user whose organization is disabled", async () => { + const fixture = await loadFixture(); + await updatePlatformOrganization(organization.id, { status: "disabled" }); + const response = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.70"); + expect({ status: response.status, body: await response.json() }).toEqual(fixture.errors.disabledOrganization); + }); +});