From b4a22d866c0142af5c5f0225fbd244650578a1a5 Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Thu, 25 Jan 2024 18:46:04 +0200 Subject: [PATCH] Add support for Device Management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Display assigned device name in the Account screen after login - Show ‘Visit Device Management’ action when presenting force log out prompt https://github.com/ivpn/desktop-app/issues/347 --- cli/commands/account.go | 7 +++ cli/protocol/client.go | 6 +- daemon/api/api.go | 4 +- daemon/api/types/responses.go | 2 + daemon/protocol/protocol.go | 13 +++-- daemon/protocol/protocol_handlers.go | 10 ++-- daemon/protocol/types/requests.go | 3 +- daemon/protocol/types/responses.go | 6 +- daemon/service/interfaces.go | 2 +- daemon/service/preferences/preferences.go | 17 ++++-- daemon/service/preferences/session.go | 1 + daemon/service/service.go | 57 ++++++++----------- ui/src/background.js | 2 +- .../components/settings/settings-account.vue | 13 +++-- ui/src/daemon-client/index.js | 18 +++--- ui/src/ipc/main-listener.js | 4 +- ui/src/ipc/renderer-sender.js | 4 +- ui/src/settings-persistent.js | 51 +---------------- ui/src/store/module-account.js | 18 ++---- ui/src/views/AccountLimit.vue | 14 +++++ 20 files changed, 115 insertions(+), 137 deletions(-) diff --git a/cli/commands/account.go b/cli/commands/account.go index 1dec56c6..637b45a0 100644 --- a/cli/commands/account.go +++ b/cli/commands/account.go @@ -108,6 +108,9 @@ func doLogin(accountID string, force bool) error { if apiStatus == types.CodeSessionsLimitReached { PrintTips([]TipType{TipForceLogin}) + + fmt.Println("Visit Device Management to manage your devices: 'https://www.ivpn.net/account/device-management'") + fmt.Println("") } if err != nil { @@ -244,6 +247,10 @@ func checkStatus() error { fmt.Fprintln(w, fmt.Sprintf("Account ID:\t%v", helloResp.Session.AccountID)) + if len(helloResp.Session.DeviceName) > 0 { + fmt.Fprintln(w, fmt.Sprintf("Device name:\t%v", helloResp.Session.DeviceName)) + } + if acc.IsFreeTrial { fmt.Fprintln(w, fmt.Sprintf("Plan:\tFree Trial")) } else { diff --git a/cli/protocol/client.go b/cli/protocol/client.go index b5dbf840..d10b0130 100644 --- a/cli/protocol/client.go +++ b/cli/protocol/client.go @@ -208,13 +208,13 @@ func (c *Client) SessionDelete(needToDisableFirewall, resetAppSettingsToDefaults } // SessionStatus get session status -func (c *Client) SessionStatus() (ret types.AccountStatusResp, err error) { +func (c *Client) SessionStatus() (ret types.SessionStatusResp, err error) { if err := c.ensureConnected(); err != nil { return ret, err } - req := types.AccountStatus{} - var resp types.AccountStatusResp + req := types.SessionStatus{} + var resp types.SessionStatusResp if err := c.sendRecv(&req, &resp); err != nil { return ret, err diff --git a/daemon/api/api.go b/daemon/api/api.go index 1c8611a6..47f52d47 100644 --- a/daemon/api/api.go +++ b/daemon/api/api.go @@ -344,7 +344,7 @@ func (a *API) SessionNew(accountID string, wgPublicKey string, kemKeys types.Kem // SessionStatus - get session status func (a *API) SessionStatus(session string) ( - *types.ServiceStatusAPIResp, + *types.SessionStatusResponse, *types.APIErrorResponse, error) { @@ -368,7 +368,7 @@ func (a *API) SessionStatus(session string) ( if err := json.Unmarshal(data, &resp); err != nil { return nil, nil, fmt.Errorf("failed to deserialize API response: %w", err) } - return &resp.ServiceStatus, &apiErr, nil + return &resp, &apiErr, nil } return nil, &apiErr, types.CreateAPIError(apiErr.Status, apiErr.Message) diff --git a/daemon/api/types/responses.go b/daemon/api/types/responses.go index 5d5e3ac3..56260595 100644 --- a/daemon/api/types/responses.go +++ b/daemon/api/types/responses.go @@ -66,6 +66,7 @@ type SessionNewResponse struct { CaptchaImage string `json:"captcha_image"` ServiceStatus ServiceStatusAPIResp `json:"service_status"` + DeviceName string `json:"device_name,omitempty"` WireGuard struct { Status int `json:"status"` @@ -92,6 +93,7 @@ type SessionsWireGuardResponse struct { type SessionStatusResponse struct { APIErrorResponse ServiceStatus ServiceStatusAPIResp `json:"service_status"` + DeviceName string `json:"device_name,omitempty"` } // GeoLookupResponse geolocation info diff --git a/daemon/protocol/protocol.go b/daemon/protocol/protocol.go index d7e3e865..36f0d47a 100644 --- a/daemon/protocol/protocol.go +++ b/daemon/protocol/protocol.go @@ -133,7 +133,7 @@ type Service interface { apiCode int, apiErrorMsg string, sessionToken string, - accountInfo preferences.AccountStatus, + sessionData preferences.SessionMutableData, err error) WireGuardGenerateKeys(updateIfNecessary bool) error @@ -1008,20 +1008,21 @@ func (p *Protocol) processRequest(conn net.Conn, message string) { // notify all clients about changed session status p.notifyClients(p.createHelloResponse()) - case "AccountStatus": - var resp types.AccountStatusResp - apiCode, apiErrMsg, sessionToken, accountInfo, err := p._service.RequestSessionStatus() + case "SessionStatus": + var resp types.SessionStatusResp + apiCode, apiErrMsg, sessionToken, sessionData, err := p._service.RequestSessionStatus() if err != nil && apiCode == 0 { // if apiCode == 0 - it is not API error. Sending error response p.sendErrorResponse(conn, reqCmd, err) break } // Sending session info - resp = types.AccountStatusResp{ + resp = types.SessionStatusResp{ APIStatus: apiCode, APIErrorMessage: apiErrMsg, SessionToken: sessionToken, - Account: accountInfo} + Account: sessionData.Account, + DeviceName: sessionData.DeviceName} // send response p.sendResponse(conn, &resp, reqCmd.Idx) diff --git a/daemon/protocol/protocol_handlers.go b/daemon/protocol/protocol_handlers.go index 207e13c4..571d4c04 100644 --- a/daemon/protocol/protocol_handlers.go +++ b/daemon/protocol/protocol_handlers.go @@ -36,15 +36,17 @@ func (p *Protocol) OnServiceSessionChanged() { p.notifyClients(helloResp) } -// OnAccountStatus - handler of account status info. Notifying clients. -func (p *Protocol) OnAccountStatus(sessionToken string, accountInfo preferences.AccountStatus) { +// OnSessionStatus - handler of session/account status info. Notifying clients. +func (p *Protocol) OnSessionStatus(sessionToken string, sessionData preferences.SessionMutableData) { if len(sessionToken) == 0 { return } - p.notifyClients(&types.AccountStatusResp{ + p.notifyClients(&types.SessionStatusResp{ SessionToken: sessionToken, - Account: accountInfo}) + Account: sessionData.Account, + DeviceName: sessionData.DeviceName, + }) } // OnKillSwitchStateChanged - Firewall change handler diff --git a/daemon/protocol/types/requests.go b/daemon/protocol/types/requests.go index dc0eeeb0..21af671a 100644 --- a/daemon/protocol/types/requests.go +++ b/daemon/protocol/types/requests.go @@ -244,8 +244,7 @@ type SessionDelete struct { IsCanDeleteSessionLocally bool } -// AccountStatus get account status -type AccountStatus struct { +type SessionStatus struct { RequestBase } diff --git a/daemon/protocol/types/responses.go b/daemon/protocol/types/responses.go index 87a4e587..7578b13a 100644 --- a/daemon/protocol/types/responses.go +++ b/daemon/protocol/types/responses.go @@ -167,6 +167,7 @@ type HelloResp struct { type SessionResp struct { AccountID string Session string + DeviceName string WgPublicKey string WgLocalIP string WgKeyGenerated int64 // Unix time @@ -179,6 +180,7 @@ func CreateSessionResp(s preferences.SessionStatus) SessionResp { return SessionResp{ AccountID: s.AccountID, Session: s.Session, + DeviceName: s.DeviceName, WgPublicKey: s.WGPublicKey, WgLocalIP: s.WGLocalIP, WgKeyGenerated: s.WGKeyGenerated.Unix(), @@ -196,13 +198,13 @@ type SessionNewResp struct { RawResponse string } -// AccountStatusResp - information about account status (or error info) -type AccountStatusResp struct { +type SessionStatusResp struct { CommandBase APIStatus int APIErrorMessage string SessionToken string Account preferences.AccountStatus + DeviceName string } // KillSwitchStatusResp returns kill-switch status diff --git a/daemon/service/interfaces.go b/daemon/service/interfaces.go index 156b87db..5c43e500 100644 --- a/daemon/service/interfaces.go +++ b/daemon/service/interfaces.go @@ -71,7 +71,7 @@ type IWgKeysManager interface { // IServiceEventsReceiver is the receiver for service events (normally, it is protocol object) type IServiceEventsReceiver interface { OnServiceSessionChanged() - OnAccountStatus(sessionToken string, account preferences.AccountStatus) + OnSessionStatus(sessionToken string, sessionData preferences.SessionMutableData) OnKillSwitchStateChanged() OnWiFiChanged(wifiNotifier.WifiInfo) OnPingStatus(retMap map[string]int) diff --git a/daemon/service/preferences/preferences.go b/daemon/service/preferences/preferences.go index a51dd766..3ba1fcba 100644 --- a/daemon/service/preferences/preferences.go +++ b/daemon/service/preferences/preferences.go @@ -113,6 +113,11 @@ type Preferences struct { WiFiControl WiFiParams } +type SessionMutableData struct { + Account AccountStatus + DeviceName string +} + func Create() *Preferences { // init default values return &Preferences{ @@ -139,6 +144,7 @@ func (p *Preferences) IsInverseSplitTunneling() bool { func (p *Preferences) SetSession(accountInfo AccountStatus, accountID string, session string, + deviceName string, vpnUser string, vpnPass string, wgPublicKey string, @@ -152,15 +158,16 @@ func (p *Preferences) SetSession(accountInfo AccountStatus, p.Account = accountInfo } - p.setSession(accountID, session, vpnUser, vpnPass, wgPublicKey, wgPrivateKey, wgLocalIP, wgPreSharedKey) + p.setSession(accountID, session, deviceName, vpnUser, vpnPass, wgPublicKey, wgPrivateKey, wgLocalIP, wgPreSharedKey) p.SavePreferences() } -func (p *Preferences) UpdateAccountInfo(acc AccountStatus) { +func (p *Preferences) UpdateSessionData(sData SessionMutableData) { if len(p.Session.AccountID) == 0 || len(p.Session.Session) == 0 { - acc = AccountStatus{} + sData = SessionMutableData{} } - p.Account = acc + p.Account = sData.Account + p.Session.DeviceName = sData.DeviceName p.SavePreferences() } @@ -293,6 +300,7 @@ func (p *Preferences) LoadPreferences() error { func (p *Preferences) setSession(accountID string, session string, + deviceName string, vpnUser string, vpnPass string, wgPublicKey string, @@ -303,6 +311,7 @@ func (p *Preferences) setSession(accountID string, p.Session = SessionStatus{ AccountID: strings.TrimSpace(accountID), Session: strings.TrimSpace(session), + DeviceName: strings.TrimSpace(deviceName), OpenVPNUser: strings.TrimSpace(vpnUser), OpenVPNPass: strings.TrimSpace(vpnPass), WGKeysRegenInerval: p.Session.WGKeysRegenInerval} // keep 'WGKeysRegenInerval' from previous Session object diff --git a/daemon/service/preferences/session.go b/daemon/service/preferences/session.go index c18ef1d6..02358ee4 100644 --- a/daemon/service/preferences/session.go +++ b/daemon/service/preferences/session.go @@ -32,6 +32,7 @@ import ( type SessionStatus struct { AccountID string Session string `json:",omitempty"` + DeviceName string `json:",omitempty"` OpenVPNUser string `json:",omitempty"` OpenVPNPass string `json:",omitempty"` WGPublicKey string diff --git a/daemon/service/service.go b/daemon/service/service.go index a8f96a5e..b22039d7 100644 --- a/daemon/service/service.go +++ b/daemon/service/service.go @@ -1521,11 +1521,12 @@ func (s *Service) SplitTunnelling_AddedPidInfo(pid int, exec string, cmdToExecut // SESSIONS ////////////////////////////////////////////////////////// -func (s *Service) setCredentials(accountInfo preferences.AccountStatus, accountID, session, vpnUser, vpnPass, wgPublicKey, wgPrivateKey, wgLocalIP string, wgKeyGenerated int64, wgPreSharedKey string) error { +func (s *Service) setCredentials(accountInfo preferences.AccountStatus, accountID, session, deviceName, vpnUser, vpnPass, wgPublicKey, wgPrivateKey, wgLocalIP string, wgKeyGenerated int64, wgPreSharedKey string) error { // save session info s._preferences.SetSession(accountInfo, accountID, session, + deviceName, vpnUser, vpnPass, wgPublicKey, @@ -1682,6 +1683,7 @@ func (s *Service) SessionNew(accountID string, forceLogin bool, captchaID string s.setCredentials(accountInfo, accountID, successResp.Token, + successResp.DeviceName, successResp.VpnUsername, successResp.VpnPassword, publicKey, @@ -1762,7 +1764,7 @@ func (s *Service) logOut(sessionNeedToDeleteOnBackend bool, isCanDeleteSessionLo } } - s._preferences.SetSession(preferences.AccountStatus{}, "", "", "", "", "", "", "", "") + s._preferences.SetSession(preferences.AccountStatus{}, "", "", "", "", "", "", "", "", "") log.Info("Logged out locally") // notify clients about session update @@ -1778,11 +1780,11 @@ func (s *Service) OnSessionNotFound() { s.logOut(needToDeleteOnBackend, canLogoutOnlyLocally) } -func (s *Service) OnAccountStatus(sessionToken string, accountInfo preferences.AccountStatus) { +func (s *Service) OnSessionStatus(sessionToken string, sessionData preferences.SessionMutableData) { // save last known info about account status - s._preferences.UpdateAccountInfo(accountInfo) + s._preferences.UpdateSessionData(sessionData) // notify about account status - s._evtReceiver.OnAccountStatus(sessionToken, accountInfo) + s._evtReceiver.OnSessionStatus(sessionToken, sessionData) } // RequestSessionStatus receives session status @@ -1790,18 +1792,18 @@ func (s *Service) RequestSessionStatus() ( apiCode int, apiErrorMsg string, sessionToken string, - accountInfo preferences.AccountStatus, + sessionStatus preferences.SessionMutableData, err error) { session := s.Preferences().Session if !session.IsLoggedIn() { - return apiCode, "", "", accountInfo, srverrors.ErrorNotLoggedIn{} + return apiCode, "", "", sessionStatus, srverrors.ErrorNotLoggedIn{} } // if no connectivity - skip request (and activate _isWaitingToUpdateAccInfoChan) if err := s.IsConnectivityBlocked(); err != nil { s._isNeedToUpdateSessionInfo = true - return apiCode, "", "", accountInfo, fmt.Errorf("session status request skipped (%w)", err) + return apiCode, "", "", sessionStatus, fmt.Errorf("session status request skipped (%w)", err) } // defer: ensure s._isWaitingToUpdateAccInfoChan is empty defer func() { @@ -1817,52 +1819,43 @@ func (s *Service) RequestSessionStatus() ( // It could happen that logout\login was performed during the session check // Ignoring result if there is already a new session log.Info("Ignoring requested session status result. Local session already changed.") - return apiCode, "", "", accountInfo, srverrors.ErrorNotLoggedIn{} + return apiCode, "", "", sessionStatus, srverrors.ErrorNotLoggedIn{} + } + + if stat != nil { + sessionStatus.Account = s.createAccountStatus(stat.ServiceStatus) + sessionStatus.DeviceName = stat.DeviceName } apiCode = 0 if apiErr != nil { apiCode = apiErr.Status - // Session not found - can happens when user forced to logout from another device if apiCode == api_types.SessionNotFound { s.OnSessionNotFound() } - // save last account info AND notify clients that account not active if apiCode == api_types.AccountNotActive { - accountInfo = preferences.AccountStatus{} - if stat != nil { - accountInfo = s.createAccountStatus(*stat) - } - accountInfo.Active = false - // notify about account status - s.OnAccountStatus(session.Session, accountInfo) - return apiCode, apiErr.Message, session.Session, accountInfo, err + sessionStatus.Account.Active = false + s.OnSessionStatus(session.Session, sessionStatus) + return apiCode, apiErr.Message, session.Session, sessionStatus, err } } if err != nil { - // in case of other API error if apiErr != nil { - return apiCode, apiErr.Message, "", accountInfo, err + return apiCode, apiErr.Message, "", sessionStatus, err } - - // not API error - return apiCode, "", "", accountInfo, err + return apiCode, "", "", sessionStatus, err } if stat == nil { - return apiCode, "", "", accountInfo, fmt.Errorf("unexpected error when creating requesting session status") + return apiCode, "", "", sessionStatus, fmt.Errorf("unexpected error when creating requesting session status") } - // get account status info - accountInfo = s.createAccountStatus(*stat) - // ave last account info AND notify about account status - s.OnAccountStatus(session.Session, accountInfo) - - // success - return apiCode, "", session.Session, accountInfo, nil + // save last account info AND notify about account status + s.OnSessionStatus(session.Session, sessionStatus) + return apiCode, "", session.Session, sessionStatus, nil } func (s *Service) createAccountStatus(apiResp api_types.ServiceStatusAPIResp) preferences.AccountStatus { diff --git a/ui/src/background.js b/ui/src/background.js index 08d0c649..cf0a88c5 100644 --- a/ui/src/background.js +++ b/ui/src/background.js @@ -503,7 +503,7 @@ if (gotTheLock) { applyMinimizedState(); break; - case "account/accountStatus": + case "account/sessionStatus": // When IVPN apps detect a plan downgrade (from Pro to Standard), an active VPN connection that uses Pro features (MultiHop or Port forwarding) // should be disconnected or reconnected with Standard plan features. // Before the active VPN connection is disconnected by the app, diff --git a/ui/src/components/settings/settings-account.vue b/ui/src/components/settings/settings-account.vue index f1af5156..975c1328 100644 --- a/ui/src/components/settings/settings-account.vue +++ b/ui/src/components/settings/settings-account.vue @@ -33,6 +33,13 @@
+
+
Device Name
+
+ {{ $store?.state?.account?.session?.DeviceName }} +
+
+
{ - return await client.AccountStatus(); +ipcMain.handle("renderer-request-session-status", async () => { + return await client.SessionStatus(); }); ipcMain.handle("renderer-request-ping-servers", async () => { diff --git a/ui/src/ipc/renderer-sender.js b/ui/src/ipc/renderer-sender.js index fe2c7caf..bcaf084d 100644 --- a/ui/src/ipc/renderer-sender.js +++ b/ui/src/ipc/renderer-sender.js @@ -117,8 +117,8 @@ export default { isCanDeleteSessionLocally ); }, - AccountStatus: async () => { - return await invoke("renderer-request-account-status"); + SessionStatus: async () => { + return await invoke("renderer-request-session-status"); }, PingServers: () => { return invoke("renderer-request-ping-servers"); diff --git a/ui/src/settings-persistent.js b/ui/src/settings-persistent.js index 32155e5a..fd466209 100644 --- a/ui/src/settings-persistent.js +++ b/ui/src/settings-persistent.js @@ -30,11 +30,9 @@ import store from "@/store"; import { DnsEncryption } from "@/store/types"; var saveSettingsTimeout = null; -var saveAccStateTimeout = null; const userDataFolder = app.getPath("userData"); const filename = path.join(userDataFolder, "ivpn-settings.json"); -const filenameAccState = path.join(userDataFolder, "acc-state.json"); export function InitPersistentSettings() { // persistent SETTINGS @@ -88,26 +86,10 @@ export function InitPersistentSettings() { } } else { console.log( - "Settings file not exist (probably, the first application start)" + "Settings file not exist (probably, the first application start)", ); } - // ACCOUNT STATE - if (fs.existsSync(filenameAccState)) { - try { - // merge data from a settings file - const data = fs.readFileSync(filenameAccState); - const accState = JSON.parse(data); - - if (accState.Active) - store.commit("account/accountStatus", { Account: accState }); - } catch (e) { - console.error(e); - } - } else { - console.log("Account state file not exist (probably, not logged in)"); - } - // STORE EVENT SUBSCRIPTION store.subscribe((mutation) => { try { @@ -120,17 +102,10 @@ export function InitPersistentSettings() { SaveSettings(); }, 2000); } - // ACCOUNT STATE - else if (mutation.type.startsWith("account/")) { - if (saveAccStateTimeout != null) clearTimeout(saveAccStateTimeout); - saveAccStateTimeout = setTimeout(() => { - SaveAccountState(); - }, 2000); - } } catch (e) { console.error( `Error in InitPersistentSettings (store.subscribe ${mutation.type}):`, - e + e, ); } }); @@ -150,28 +125,6 @@ export function SaveSettings() { } } -export function SaveAccountState() { - if (saveAccStateTimeout == null) return; - - clearTimeout(saveAccStateTimeout); - saveAccStateTimeout = null; - - try { - if ( - store.getters["account/isLoggedIn"] !== true || - !store.state.account || - !store.state.account.accountStatus - ) { - if (fs.existsSync(filenameAccState)) fs.unlinkSync(filenameAccState); - } else { - let data = JSON.stringify(store.state.account.accountStatus, null, 2); - fs.writeFileSync(filenameAccState, data); - } - } catch (e) { - console.error("Failed to save account state:" + e); - } -} - function combineMerge(target, source, options) { const emptyTarget = (value) => (Array.isArray(value) ? [] : {}); const clone = (value, options) => merge(emptyTarget(value), value, options); diff --git a/ui/src/store/module-account.js b/ui/src/store/module-account.js index dac8f9ad..8d9d5cc4 100644 --- a/ui/src/store/module-account.js +++ b/ui/src/store/module-account.js @@ -31,6 +31,7 @@ export default { session: { AccountID: "", Session: "", + DeviceName: "", WgPublicKey: "", WgLocalIP: "", WgUsePresharedKey: false, @@ -66,7 +67,7 @@ export default { ) state.accountStatus = null; }, - accountStatus(state, accState) { + sessionStatus(state, accState) { if ( accState == null || accState.Account == null || @@ -76,16 +77,7 @@ export default { ) return; - /* - // ACCOUNT EXPIRATION TEST! - accState.Account.IsFreeTrial = true; - var result = new Date(); - result.setDate(result.getDate() + 1); - accState.Account.ActiveUntil = Math.round(result / 1000); - accState.Account.WillAutoRebill = false; - //accState.Account.Active = false; - */ - + state.session.DeviceName = accState.DeviceName; state.accountStatus = accState.Account; // save session for account status object @@ -153,8 +145,8 @@ export default { }, actions: { - accountStatus(context, val) { - context.commit("accountStatus", val); + sessionStatus(context, val) { + context.commit("sessionStatus", val); if (context.getters.isMultihopAllowed === false) // TODO: have to be removed from here (potential problem example: VPN is connected multihop but multihop not allowed) diff --git a/ui/src/views/AccountLimit.vue b/ui/src/views/AccountLimit.vue index e2a84816..9227fa44 100644 --- a/ui/src/views/AccountLimit.vue +++ b/ui/src/views/AccountLimit.vue @@ -28,6 +28,15 @@ Log out from all devices +
+ +
@@ -115,6 +124,11 @@ export default { onContactSupport: function () { sender.shellOpenExternal(`https://www.ivpn.net/contactus`); }, + onVisitDeviceManagement: function () { + sender.shellOpenExternal( + `https://www.ivpn.net/account/device-management`, + ); + }, }, };