[client] Display login popup on session expiration (#3955)

This PR implements a feature enhancement to display a login popup when the session expires. Key changes include updating flag handling and client construction to support a new login URL popup, revising login and notification handling logic to use the new popup, and updating status and server-side session state management accordingly.
This commit is contained in:
hakansa
2025-06-14 00:51:57 +03:00
committed by GitHub
parent 04a3765391
commit 089d442fb2
5 changed files with 124 additions and 36 deletions

View File

@@ -69,7 +69,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
return err return err
} }
if resp.GetStatus() == string(internal.StatusNeedsLogin) || resp.GetStatus() == string(internal.StatusLoginFailed) { status := resp.GetStatus()
if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
status == string(internal.StatusSessionExpired) {
cmd.Printf("Daemon status: %s\n\n"+ cmd.Printf("Daemon status: %s\n\n"+
"Run UP command to log in with SSO (interactive login):\n\n"+ "Run UP command to log in with SSO (interactive login):\n\n"+
" netbird up \n\n"+ " netbird up \n\n"+

View File

@@ -10,10 +10,11 @@ type StatusType string
const ( const (
StatusIdle StatusType = "Idle" StatusIdle StatusType = "Idle"
StatusConnecting StatusType = "Connecting" StatusConnecting StatusType = "Connecting"
StatusConnected StatusType = "Connected" StatusConnected StatusType = "Connected"
StatusNeedsLogin StatusType = "NeedsLogin" StatusNeedsLogin StatusType = "NeedsLogin"
StatusLoginFailed StatusType = "LoginFailed" StatusLoginFailed StatusType = "LoginFailed"
StatusSessionExpired StatusType = "SessionExpired"
) )
// CtxInitState setup context state into the context tree. // CtxInitState setup context state into the context tree.

View File

@@ -8,6 +8,7 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
@@ -66,6 +67,7 @@ type Server struct {
lastProbe time.Time lastProbe time.Time
persistNetworkMap bool persistNetworkMap bool
isSessionActive atomic.Bool
} }
type oauthAuthFlow struct { type oauthAuthFlow struct {
@@ -567,9 +569,6 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
tokenInfo, err := s.oauthAuthFlow.flow.WaitToken(waitCTX, flowInfo) tokenInfo, err := s.oauthAuthFlow.flow.WaitToken(waitCTX, flowInfo)
if err != nil { if err != nil {
if err == context.Canceled {
return nil, nil //nolint:nilnil
}
s.mutex.Lock() s.mutex.Lock()
s.oauthAuthFlow.expiresAt = time.Now() s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock() s.mutex.Unlock()
@@ -640,6 +639,7 @@ func (s *Server) Up(callerCtx context.Context, _ *proto.UpRequest) (*proto.UpRes
for { for {
select { select {
case <-runningChan: case <-runningChan:
s.isSessionActive.Store(true)
return &proto.UpResponse{}, nil return &proto.UpResponse{}, nil
case <-callerCtx.Done(): case <-callerCtx.Done():
log.Debug("context done, stopping the wait for engine to become ready") log.Debug("context done, stopping the wait for engine to become ready")
@@ -668,6 +668,7 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
log.Errorf("failed to shut down properly: %v", err) log.Errorf("failed to shut down properly: %v", err)
return nil, err return nil, err
} }
s.isSessionActive.Store(false)
state := internal.CtxGetState(s.rootCtx) state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusIdle) state.Set(internal.StatusIdle)
@@ -694,6 +695,12 @@ func (s *Server) Status(
return nil, err return nil, err
} }
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
log.Debug("status requested while session is active, returning SessionExpired")
status = internal.StatusSessionExpired
s.isSessionActive.Store(false)
}
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()} statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())

View File

@@ -20,7 +20,10 @@ import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"fyne.io/systray" "fyne.io/systray"
@@ -51,7 +54,7 @@ const (
) )
func main() { func main() {
daemonAddr, showSettings, showNetworks, showDebug, errorMsg, saveLogsInFile := parseFlags() daemonAddr, showSettings, showNetworks, showLoginURL, showDebug, errorMsg, saveLogsInFile := parseFlags()
// Initialize file logging if needed. // Initialize file logging if needed.
var logFile string var logFile string
@@ -77,13 +80,13 @@ func main() {
} }
// Create the service client (this also builds the settings or networks UI if requested). // Create the service client (this also builds the settings or networks UI if requested).
client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showDebug) client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showLoginURL, showDebug)
// Watch for theme/settings changes to update the icon. // Watch for theme/settings changes to update the icon.
go watchSettingsChanges(a, client) go watchSettingsChanges(a, client)
// Run in window mode if any UI flag was set. // Run in window mode if any UI flag was set.
if showSettings || showNetworks || showDebug { if showSettings || showNetworks || showDebug || showLoginURL {
a.Run() a.Run()
return return
} }
@@ -104,7 +107,7 @@ func main() {
} }
// parseFlags reads and returns all needed command-line flags. // parseFlags reads and returns all needed command-line flags.
func parseFlags() (daemonAddr string, showSettings, showNetworks, showDebug bool, errorMsg string, saveLogsInFile bool) { func parseFlags() (daemonAddr string, showSettings, showNetworks, showLoginURL, showDebug bool, errorMsg string, saveLogsInFile bool) {
defaultDaemonAddr := "unix:///var/run/netbird.sock" defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
defaultDaemonAddr = "tcp://127.0.0.1:41731" defaultDaemonAddr = "tcp://127.0.0.1:41731"
@@ -112,6 +115,7 @@ func parseFlags() (daemonAddr string, showSettings, showNetworks, showDebug bool
flag.StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") flag.StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
flag.BoolVar(&showSettings, "settings", false, "run settings window") flag.BoolVar(&showSettings, "settings", false, "run settings window")
flag.BoolVar(&showNetworks, "networks", false, "run networks window") flag.BoolVar(&showNetworks, "networks", false, "run networks window")
flag.BoolVar(&showLoginURL, "login-url", false, "show login URL in a popup window")
flag.BoolVar(&showDebug, "debug", false, "run debug window") flag.BoolVar(&showDebug, "debug", false, "run debug window")
flag.StringVar(&errorMsg, "error-msg", "", "displays an error message window") flag.StringVar(&errorMsg, "error-msg", "", "displays an error message window")
flag.BoolVar(&saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir())) flag.BoolVar(&saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
@@ -253,6 +257,7 @@ type serviceClient struct {
exitNodeStates []exitNodeState exitNodeStates []exitNodeState
mExitNodeDeselectAll *systray.MenuItem mExitNodeDeselectAll *systray.MenuItem
logFile string logFile string
wLoginURL fyne.Window
} }
type menuHandler struct { type menuHandler struct {
@@ -263,7 +268,7 @@ type menuHandler struct {
// newServiceClient instance constructor // newServiceClient instance constructor
// //
// This constructor also builds the UI elements for the settings window. // This constructor also builds the UI elements for the settings window.
func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showDebug bool) *serviceClient { func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showLoginURL bool, showDebug bool) *serviceClient {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
s := &serviceClient{ s := &serviceClient{
ctx: ctx, ctx: ctx,
@@ -286,6 +291,8 @@ func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool
s.showSettingsUI() s.showSettingsUI()
case showNetworks: case showNetworks:
s.showNetworksUI() s.showNetworksUI()
case showLoginURL:
s.showLoginURL()
case showDebug: case showDebug:
s.showDebugUI() s.showDebugUI()
} }
@@ -445,11 +452,11 @@ func (s *serviceClient) getSettingsForm() *widget.Form {
} }
} }
func (s *serviceClient) login() error { func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
conn, err := s.getSrvClient(defaultFailTimeout) conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil { if err != nil {
log.Errorf("get client: %v", err) log.Errorf("get client: %v", err)
return err return nil, err
} }
loginResp, err := conn.Login(s.ctx, &proto.LoginRequest{ loginResp, err := conn.Login(s.ctx, &proto.LoginRequest{
@@ -457,24 +464,24 @@ func (s *serviceClient) login() error {
}) })
if err != nil { if err != nil {
log.Errorf("login to management URL with: %v", err) log.Errorf("login to management URL with: %v", err)
return err return nil, err
} }
if loginResp.NeedsSSOLogin { if loginResp.NeedsSSOLogin && openURL {
err = open.Run(loginResp.VerificationURIComplete) err = open.Run(loginResp.VerificationURIComplete)
if err != nil { if err != nil {
log.Errorf("opening the verification uri in the browser failed: %v", err) log.Errorf("opening the verification uri in the browser failed: %v", err)
return err return nil, err
} }
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode}) _, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
if err != nil { if err != nil {
log.Errorf("waiting sso login failed with: %v", err) log.Errorf("waiting sso login failed with: %v", err)
return err return nil, err
} }
} }
return nil return loginResp, nil
} }
func (s *serviceClient) menuUpClick() error { func (s *serviceClient) menuUpClick() error {
@@ -486,7 +493,7 @@ func (s *serviceClient) menuUpClick() error {
return err return err
} }
err = s.login() _, err = s.login(true)
if err != nil { if err != nil {
log.Errorf("login failed with: %v", err) log.Errorf("login failed with: %v", err)
return err return err
@@ -558,7 +565,7 @@ func (s *serviceClient) updateStatus() error {
defer s.updateIndicationLock.Unlock() defer s.updateIndicationLock.Unlock()
// notify the user when the session has expired // notify the user when the session has expired
if status.Status == string(internal.StatusNeedsLogin) { if status.Status == string(internal.StatusSessionExpired) {
s.onSessionExpire() s.onSessionExpire()
} }
@@ -732,7 +739,6 @@ func (s *serviceClient) onTrayReady() {
go s.eventHandler.listen(s.ctx) go s.eventHandler.listen(s.ctx)
} }
func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File { func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File {
if s.logFile == "" { if s.logFile == "" {
// attach child's streams to parent's streams // attach child's streams to parent's streams
@@ -871,17 +877,9 @@ func (s *serviceClient) onUpdateAvailable() {
// onSessionExpire sends a notification to the user when the session expires. // onSessionExpire sends a notification to the user when the session expires.
func (s *serviceClient) onSessionExpire() { func (s *serviceClient) onSessionExpire() {
s.sendNotification = true
if s.sendNotification { if s.sendNotification {
title := "Connection session expired" s.eventHandler.runSelfCommand("login-url", "true")
if runtime.GOOS == "darwin" {
title = "NetBird connection session expired"
}
s.app.SendNotification(
fyne.NewNotification(
title,
"Please re-authenticate to connect to the network",
),
)
s.sendNotification = false s.sendNotification = false
} }
} }
@@ -955,9 +953,9 @@ func (s *serviceClient) updateConfig() error {
ServerSSHAllowed: &sshAllowed, ServerSSHAllowed: &sshAllowed,
RosenpassEnabled: &rosenpassEnabled, RosenpassEnabled: &rosenpassEnabled,
DisableAutoConnect: &disableAutoStart, DisableAutoConnect: &disableAutoStart,
DisableNotifications: &notificationsDisabled,
LazyConnectionEnabled: &lazyConnectionEnabled, LazyConnectionEnabled: &lazyConnectionEnabled,
BlockInbound: &blockInbound, BlockInbound: &blockInbound,
DisableNotifications: &notificationsDisabled,
} }
if err := s.restartClient(&loginRequest); err != nil { if err := s.restartClient(&loginRequest); err != nil {
@@ -991,6 +989,87 @@ func (s *serviceClient) restartClient(loginRequest *proto.LoginRequest) error {
return nil return nil
} }
// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL.
func (s *serviceClient) showLoginURL() {
resp, err := s.login(false)
if err != nil {
log.Errorf("failed to fetch login URL: %v", err)
return
}
verificationURL := resp.VerificationURIComplete
if verificationURL == "" {
verificationURL = resp.VerificationURI
}
if verificationURL == "" {
log.Error("no verification URL provided in the login response")
return
}
resIcon := fyne.NewStaticResource("netbird.png", iconAbout)
if s.wLoginURL == nil {
s.wLoginURL = s.app.NewWindow("NetBird Session Expired")
s.wLoginURL.Resize(fyne.NewSize(400, 200))
s.wLoginURL.SetIcon(resIcon)
}
// add a description label
label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.")
btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("get client: %v", err)
return
}
if err := openURL(verificationURL); err != nil {
log.Errorf("failed to open login URL: %v", err)
return
}
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode})
if err != nil {
log.Errorf("Waiting sso login failed with: %v", err)
label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.")
return
}
label.SetText("Re-authentication successful.\nReconnecting")
time.Sleep(300 * time.Millisecond)
_, err = conn.Up(s.ctx, &proto.UpRequest{})
if err != nil {
label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.")
log.Errorf("Reconnecting failed with: %v", err)
return
}
label.SetText("Connection successful.\nClosing this window.")
time.Sleep(time.Second)
s.wLoginURL.Close()
})
img := canvas.NewImageFromResource(resIcon)
img.FillMode = canvas.ImageFillContain
img.SetMinSize(fyne.NewSize(64, 64))
img.Resize(fyne.NewSize(64, 64))
// center the content vertically
content := container.NewVBox(
layout.NewSpacer(),
img,
label,
btn,
layout.NewSpacer(),
)
s.wLoginURL.SetContent(container.NewCenter(content))
s.wLoginURL.Show()
}
func openURL(url string) error { func openURL(url string) error {
var err error var err error
switch runtime.GOOS { switch runtime.GOOS {

View File

@@ -358,8 +358,6 @@ func (s *serviceClient) updateExitNodes() {
} else { } else {
s.mExitNode.Disable() s.mExitNode.Disable()
} }
log.Debugf("Exit nodes updated: %d", len(s.mExitNodeItems))
} }
func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {