From aa1ba12a0b8a8d1adbb8b1238ce8115c57cd74ae Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:23:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=86=99=E5=9B=9E=E4=BF=9D=E7=95=99=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E7=8A=B6=E6=80=81=20(#685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- backend/internal/auth/auth.go | 2 +- backend/internal/auth/linuxdo.go | 2 +- backend/internal/auth/login_status_test.go | 127 ++++++++++++++++++ .../internal/repository/repository_users.go | 6 + 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 backend/internal/auth/login_status_test.go diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index b79fc6841..a3633d453 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -281,7 +281,7 @@ func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) { now := time.Now() user.LastLoginAt = &now user.UpdatedAt = now - if err := s.repo.Save(user); err != nil { + if err := s.repo.UpdateUserLoginTime(user.ID, now); err != nil { return nil, err } if err := s.host.EnsureSignupBonus(user.ID); err != nil { diff --git a/backend/internal/auth/linuxdo.go b/backend/internal/auth/linuxdo.go index 9ab05a1b4..f5b8dc0f7 100644 --- a/backend/internal/auth/linuxdo.go +++ b/backend/internal/auth/linuxdo.go @@ -263,7 +263,7 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO now := time.Now() user.LastLoginAt = &now user.UpdatedAt = now - if err := s.repo.Save(user); err != nil { + if err := s.repo.UpdateUserLoginTime(user.ID, now); err != nil { return nil, err } s.host.RecordActivity(user.ID, "login", 1) diff --git a/backend/internal/auth/login_status_test.go b/backend/internal/auth/login_status_test.go new file mode 100644 index 000000000..430671bf1 --- /dev/null +++ b/backend/internal/auth/login_status_test.go @@ -0,0 +1,127 @@ +package auth + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/outbound" + + "gorm.io/gorm" +) + +// Commit the administrator's change after the login read, before its user update. +func disableBeforeLoginWrite(t *testing.T, db *gorm.DB, userID string) *bool { + t.Helper() + fired := false + if err := db.Callback().Update().Before("gorm:begin_transaction").Register("test:disable_before_login_write", func(tx *gorm.DB) { + if fired || tx.Statement.Schema == nil || tx.Statement.Schema.Table != "users" { + return + } + fired = true + if err := db.Exec("UPDATE users SET status = ?, display_name = ? WHERE id = ?", model.UserStatusDisabled, "Admin Edit", userID).Error; err != nil { + tx.AddError(err) + } + }); err != nil { + t.Fatal(err) + } + return &fired +} + +func assertLoginPreservesDisable(t *testing.T, svc *Service, db *gorm.DB, fired *bool, result *AuthSessionResult) { + t.Helper() + if !*fired { + t.Fatal("concurrent disable was not injected") + } + var user model.User + if err := db.First(&user, "id = ?", "member").Error; err != nil { + t.Fatal(err) + } + if user.Status != model.UserStatusDisabled { + t.Errorf("status after login = %q, want disabled", user.Status) + } + if user.DisplayName != "Admin Edit" { + t.Errorf("display name after login = %q, want Admin Edit", user.DisplayName) + } + if user.LastLoginAt == nil || user.UpdatedAt.Before(*user.LastLoginAt) { + t.Error("login timestamps were not updated") + } + if _, err := svc.CurrentUser(result.Session); err == nil { + t.Error("disabled user can use the completed login session") + } +} + +func TestPasswordLoginPreservesConcurrentDisable(t *testing.T) { + svc, db := newRegistrationTestService(t) + hash, err := HashPassword("password") + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.User{ID: "member", Username: "member", DisplayName: "Before", PasswordHash: hash, Role: model.UserRoleUser, Status: model.UserStatusActive}).Error; err != nil { + t.Fatal(err) + } + fired := disableBeforeLoginWrite(t, db, "member") + result, err := svc.Login(LoginRequest{Username: "member", Password: "password"}) + if err != nil { + t.Fatal(err) + } + assertLoginPreservesDisable(t, svc, db, fired, result) +} + +func TestLinuxDOLoginPreservesConcurrentDisable(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + // Exercise the OAuth HTTP path over an in-memory connection, without sockets. + transport := outbound.OutboundHTTPClient(time.Second).Transport.(*http.Transport) + transport.CloseIdleConnections() + previousDial := transport.DialContext + transport.DialContext = func(context.Context, string, string) (net.Conn, error) { + client, server := net.Pipe() + go func() { + defer server.Close() + req, err := http.ReadRequest(bufio.NewReader(server)) + if err != nil { + t.Error(err) + return + } + defer req.Body.Close() + if _, err := io.Copy(io.Discard, req.Body); err != nil { + t.Error(err) + return + } + body := `{"id":"42","username":"member"}` + if req.URL.Path == "/token" { + body = `{"access_token":"test-token"}` + } + _, _ = fmt.Fprintf(server, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body) + }() + return client, nil + } + t.Cleanup(func() { transport.CloseIdleConnections(); transport.DialContext = previousDial }) + svc, db := newRegistrationTestService(t) + if err := svc.repo.CreateOAuthUser(&model.User{ID: "member", Username: "member", DisplayName: "Before", Role: model.UserRoleUser, Status: model.UserStatusActive}, &model.UserIdentity{ID: "identity", UserID: "member", Provider: "linuxdo", Subject: "42"}); err != nil { + t.Fatal(err) + } + setting, err := json.Marshal(linuxDOSettingValue{Enabled: true, ClientID: "client", TokenURL: "http://127.0.0.1/token", UserInfoURL: "http://127.0.0.1/profile"}) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: linuxDOSettingKey, ValueJSON: string(setting)}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.OAuthState{ID: "state", Provider: "linuxdo", StateHash: HashToken("state-value"), ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil { + t.Fatal(err) + } + fired := disableBeforeLoginWrite(t, db, "member") + result, err := svc.CompleteLinuxDOLogin("state-value", "code") + if err != nil { + t.Fatal(err) + } + assertLoginPreservesDisable(t, svc, db, fired, result.Session) +} diff --git a/backend/internal/repository/repository_users.go b/backend/internal/repository/repository_users.go index e36aa2d70..8c02ad5f3 100644 --- a/backend/internal/repository/repository_users.go +++ b/backend/internal/repository/repository_users.go @@ -27,6 +27,12 @@ func (r *Repository) User(id string) (*model.User, error) { return &user, nil } +// UpdateUserLoginTime preserves concurrent changes to status and profile fields. +func (r *Repository) UpdateUserLoginTime(userID string, now time.Time) error { + return r.db.Model(&model.User{}).Where("id = ?", userID). + Updates(map[string]any{"last_login_at": now, "updated_at": now}).Error +} + func (r *Repository) UserByAccount(account string) (*model.User, error) { var user model.User if err := r.db.Where("lower(username) = lower(?) OR lower(email) = lower(?)", account, account).First(&user).Error; err != nil {