feat: add Go password session lifecycle

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

View File

@@ -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{

View File

@@ -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