feat: add Go password session lifecycle
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user