feat: Implement handling soft logout

This commit is contained in:
Krille 2024-02-16 09:16:51 +01:00
parent d7596923ad
commit 38b1eb75e5
No known key found for this signature in database
GPG Key ID: E067ECD60F1A0652
10 changed files with 153 additions and 2 deletions

View File

@ -89,6 +89,8 @@ class Client extends MatrixApi {
bool shareKeysWithUnverifiedDevices; bool shareKeysWithUnverifiedDevices;
Future<void> Function(Client client)? onSoftLogout;
// For CommandsClientExtension // For CommandsClientExtension
final Map<String, FutureOr<String?> Function(CommandArgs)> commands = {}; final Map<String, FutureOr<String?> Function(CommandArgs)> commands = {};
final Filter syncFilter; final Filter syncFilter;
@ -184,6 +186,13 @@ class Client extends MatrixApi {
this.shareKeysWithUnverifiedDevices = true, this.shareKeysWithUnverifiedDevices = true,
this.enableDehydratedDevices = false, this.enableDehydratedDevices = false,
this.receiptsPublicByDefault = true, this.receiptsPublicByDefault = true,
/// Implement your https://spec.matrix.org/v1.9/client-server-api/#soft-logout
/// logic here.
/// Set this to `refreshAccessToken()` for the easiest way to handle the
/// most common reason for soft logouts.
/// You can also perform a new login here by passing the existing deviceId.
this.onSoftLogout,
}) : syncFilter = syncFilter ?? }) : syncFilter = syncFilter ??
Filter( Filter(
room: RoomFilter( room: RoomFilter(
@ -234,6 +243,40 @@ class Client extends MatrixApi {
registerDefaultCommands(); registerDefaultCommands();
} }
/// Fetches the refreshToken from the database and tries to get a new
/// access token from the server and then stores it correctly. Unlike the
/// pure API call of `Client.refresh()` this handles the complete soft
/// logout case.
/// Throws an Exception if there is no refresh token available or the
/// client is not logged in.
Future<void> refreshAccessToken() async {
final storedClient = await database?.getClient(clientName);
final refreshToken = storedClient?.tryGet<String>('refresh_token');
if (refreshToken == null) {
throw Exception('No refresh token available');
}
final homeserverUrl = homeserver?.toString();
final userId = userID;
final deviceId = deviceID;
if (homeserverUrl == null || userId == null || deviceId == null) {
throw Exception('Cannot refresh access token when not logged in');
}
final tokenResponse = await refresh(refreshToken);
accessToken = tokenResponse.accessToken;
await database?.updateClient(
homeserverUrl,
tokenResponse.accessToken,
tokenResponse.refreshToken,
userId,
deviceId,
deviceName,
prevBatch,
encryption?.pickledOlmAccount,
);
}
/// The required name for this client. /// The required name for this client.
final String clientName; final String clientName;
@ -485,6 +528,7 @@ class Client extends MatrixApi {
deviceId: deviceId, deviceId: deviceId,
initialDeviceDisplayName: initialDeviceDisplayName, initialDeviceDisplayName: initialDeviceDisplayName,
inhibitLogin: inhibitLogin, inhibitLogin: inhibitLogin,
refreshToken: refreshToken ?? onSoftLogout != null,
); );
// Connect if there is an access token in the response. // Connect if there is an access token in the response.
@ -498,6 +542,7 @@ class Client extends MatrixApi {
} }
await init( await init(
newToken: accessToken, newToken: accessToken,
newRefreshToken: response.refreshToken,
newUserID: userId, newUserID: userId,
newHomeserver: homeserver, newHomeserver: homeserver,
newDeviceName: initialDeviceDisplayName ?? '', newDeviceName: initialDeviceDisplayName ?? '',
@ -548,6 +593,7 @@ class Client extends MatrixApi {
medium: medium, medium: medium,
// ignore: deprecated_member_use // ignore: deprecated_member_use
address: address, address: address,
refreshToken: refreshToken ?? onSoftLogout != null,
); );
// Connect if there is an access token in the response. // Connect if there is an access token in the response.
@ -560,6 +606,7 @@ class Client extends MatrixApi {
} }
await init( await init(
newToken: accessToken, newToken: accessToken,
newRefreshToken: response.refreshToken,
newUserID: userId, newUserID: userId,
newHomeserver: homeserver_, newHomeserver: homeserver_,
newDeviceName: initialDeviceDisplayName ?? '', newDeviceName: initialDeviceDisplayName ?? '',
@ -1474,6 +1521,7 @@ class Client extends MatrixApi {
/// `userDeviceKeysLoading` where it is necessary. /// `userDeviceKeysLoading` where it is necessary.
Future<void> init({ Future<void> init({
String? newToken, String? newToken,
String? newRefreshToken,
Uri? newHomeserver, Uri? newHomeserver,
String? newUserID, String? newUserID,
String? newDeviceName, String? newDeviceName,
@ -1587,6 +1635,7 @@ class Client extends MatrixApi {
await database.updateClient( await database.updateClient(
homeserver.toString(), homeserver.toString(),
accessToken, accessToken,
newRefreshToken,
userID, userID,
_deviceID, _deviceID,
_deviceName, _deviceName,
@ -1598,6 +1647,7 @@ class Client extends MatrixApi {
clientName, clientName,
homeserver.toString(), homeserver.toString(),
accessToken, accessToken,
newRefreshToken,
userID, userID,
_deviceID, _deviceID,
_deviceName, _deviceName,
@ -1822,8 +1872,19 @@ class Client extends MatrixApi {
onSyncStatus.add(SyncStatusUpdate(SyncStatus.error, onSyncStatus.add(SyncStatusUpdate(SyncStatus.error,
error: SdkError(exception: e, stackTrace: s))); error: SdkError(exception: e, stackTrace: s)));
if (e.error == MatrixError.M_UNKNOWN_TOKEN) { if (e.error == MatrixError.M_UNKNOWN_TOKEN) {
Logs().w('The user has been logged out!'); final onSoftLogout = this.onSoftLogout;
await clear(); if (e.raw.tryGet<bool>('soft_logout') == true && onSoftLogout != null) {
Logs().w('The user has been soft logged out! Try to login again...');
try {
await onSoftLogout(this);
} catch (e, s) {
Logs().e('Unable to login again', e, s);
await clear();
}
} else {
Logs().w('The user has been logged out!');
await clear();
}
} }
} on SyncConnectionException catch (e, s) { } on SyncConnectionException catch (e, s) {
Logs().w('Syncloop failed: Client has not connection to the server'); Logs().w('Syncloop failed: Client has not connection to the server');
@ -3112,6 +3173,7 @@ class Client extends MatrixApi {
clientName, clientName,
migrateClient['homeserver_url'], migrateClient['homeserver_url'],
migrateClient['token'], migrateClient['token'],
migrateClient['refresh_token'],
migrateClient['user_id'], migrateClient['user_id'],
migrateClient['device_id'], migrateClient['device_id'],
migrateClient['device_name'], migrateClient['device_name'],

View File

@ -33,6 +33,7 @@ abstract class DatabaseApi {
Future updateClient( Future updateClient(
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -44,6 +45,7 @@ abstract class DatabaseApi {
String name, String name,
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,

View File

@ -785,6 +785,7 @@ class HiveCollectionsDatabase extends DatabaseApi {
String name, String name,
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -794,6 +795,11 @@ class HiveCollectionsDatabase extends DatabaseApi {
await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token); await _clientBox.put('token', token);
await _clientBox.put('user_id', userId); await _clientBox.put('user_id', userId);
if (refreshToken == null) {
await _clientBox.delete('refresh_token');
} else {
await _clientBox.put('refresh_token', refreshToken);
}
if (deviceId == null) { if (deviceId == null) {
await _clientBox.delete('device_id'); await _clientBox.delete('device_id');
} else { } else {
@ -1371,6 +1377,7 @@ class HiveCollectionsDatabase extends DatabaseApi {
Future<void> updateClient( Future<void> updateClient(
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -1380,6 +1387,11 @@ class HiveCollectionsDatabase extends DatabaseApi {
await transaction(() async { await transaction(() async {
await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token); await _clientBox.put('token', token);
if (refreshToken == null) {
await _clientBox.delete('refresh_token');
} else {
await _clientBox.put('refresh_token', refreshToken);
}
await _clientBox.put('user_id', userId); await _clientBox.put('user_id', userId);
if (deviceId == null) { if (deviceId == null) {
await _clientBox.delete('device_id'); await _clientBox.delete('device_id');

View File

@ -750,6 +750,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin {
String name, String name,
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -757,6 +758,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin {
String? olmAccount) async { String? olmAccount) async {
await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token); await _clientBox.put('token', token);
await _clientBox.put('refresh_token', refreshToken);
await _clientBox.put('user_id', userId); await _clientBox.put('user_id', userId);
await _clientBox.put('device_id', deviceId); await _clientBox.put('device_id', deviceId);
await _clientBox.put('device_name', deviceName); await _clientBox.put('device_name', deviceName);
@ -1314,6 +1316,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin {
Future<void> updateClient( Future<void> updateClient(
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -1322,6 +1325,7 @@ class FamedlySdkHiveDatabase extends DatabaseApi with ZoneTransactionMixin {
) async { ) async {
await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token); await _clientBox.put('token', token);
await _clientBox.put('refresh_token', refreshToken);
await _clientBox.put('user_id', userId); await _clientBox.put('user_id', userId);
await _clientBox.put('device_id', deviceId); await _clientBox.put('device_id', deviceId);
await _clientBox.put('device_name', deviceName); await _clientBox.put('device_name', deviceName);

View File

@ -727,6 +727,7 @@ class MatrixSdkDatabase extends DatabaseApi {
String name, String name,
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -735,6 +736,11 @@ class MatrixSdkDatabase extends DatabaseApi {
await transaction(() async { await transaction(() async {
await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token); await _clientBox.put('token', token);
if (refreshToken == null) {
await _clientBox.delete('refresh_token');
} else {
await _clientBox.put('refresh_token', refreshToken);
}
await _clientBox.put('user_id', userId); await _clientBox.put('user_id', userId);
if (deviceId == null) { if (deviceId == null) {
await _clientBox.delete('device_id'); await _clientBox.delete('device_id');
@ -1343,6 +1349,7 @@ class MatrixSdkDatabase extends DatabaseApi {
Future<void> updateClient( Future<void> updateClient(
String homeserverUrl, String homeserverUrl,
String token, String token,
String? refreshToken,
String userId, String userId,
String? deviceId, String? deviceId,
String? deviceName, String? deviceName,
@ -1352,6 +1359,11 @@ class MatrixSdkDatabase extends DatabaseApi {
await transaction(() async { await transaction(() async {
await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('homeserver_url', homeserverUrl);
await _clientBox.put('token', token); await _clientBox.put('token', token);
if (refreshToken == null) {
await _clientBox.delete('refresh_token');
} else {
await _clientBox.put('refresh_token', refreshToken);
}
await _clientBox.put('user_id', userId); await _clientBox.put('user_id', userId);
if (deviceId == null) { if (deviceId == null) {
await _clientBox.delete('device_id'); await _clientBox.delete('device_id');

View File

@ -964,6 +964,35 @@ void main() {
await client.dispose(closeDatabase: true); await client.dispose(closeDatabase: true);
}); });
test('refreshAccessToken', () async {
final client = await getClient();
expect(client.accessToken, 'abcd');
await client.refreshAccessToken();
expect(client.accessToken, 'a_new_token');
});
test('handleSoftLogout', () async {
final client = await getClient();
expect(client.accessToken, 'abcd');
var softLoggedOut = 0;
client.onSoftLogout = (client) {
softLoggedOut++;
return client.refreshAccessToken();
};
FakeMatrixApi.expectedAccessToken = 'a_new_token';
await client.oneShotSync();
await client.oneShotSync();
FakeMatrixApi.expectedAccessToken = null;
expect(client.accessToken, 'a_new_token');
expect(softLoggedOut, 1);
final storedClient = await client.database?.getClient(client.clientName);
expect(storedClient?.tryGet<String>('token'), 'a_new_token');
expect(
storedClient?.tryGet<String>('refresh_token'),
'another_new_token',
);
});
test('object equality', () async { test('object equality', () async {
final time1 = DateTime.fromMillisecondsSinceEpoch(1); final time1 = DateTime.fromMillisecondsSinceEpoch(1);
final time2 = DateTime.fromMillisecondsSinceEpoch(0); final time2 = DateTime.fromMillisecondsSinceEpoch(0);

View File

@ -133,6 +133,7 @@ void main() {
'name', 'name',
'homeserverUrl', 'homeserverUrl',
'token', 'token',
'refresh_token',
'userId', 'userId',
'deviceId', 'deviceId',
'deviceName', 'deviceName',
@ -147,6 +148,7 @@ void main() {
await database.updateClient( await database.updateClient(
'homeserverUrl', 'homeserverUrl',
'token_different', 'token_different',
'refresh_token',
'userId', 'userId',
'deviceId', 'deviceId',
'deviceName', 'deviceName',

View File

@ -32,12 +32,14 @@ Future<Client> getClient() async {
'testclient', 'testclient',
httpClient: FakeMatrixApi(), httpClient: FakeMatrixApi(),
databaseBuilder: getDatabase, databaseBuilder: getDatabase,
onSoftLogout: (client) => client.refreshAccessToken(),
); );
FakeMatrixApi.client = client; FakeMatrixApi.client = client;
await client.checkHomeserver(Uri.parse('https://fakeServer.notExisting'), await client.checkHomeserver(Uri.parse('https://fakeServer.notExisting'),
checkWellKnown: false); checkWellKnown: false);
await client.init( await client.init(
newToken: 'abcd', newToken: 'abcd',
newRefreshToken: 'refresh_abcd',
newUserID: '@test:fakeServer.notExisting', newUserID: '@test:fakeServer.notExisting',
newHomeserver: client.homeserver, newHomeserver: client.homeserver,
newDeviceName: 'Text Matrix Client', newDeviceName: 'Text Matrix Client',

View File

@ -39,6 +39,8 @@ Map<String, dynamic> decodeJson(dynamic data) {
} }
class FakeMatrixApi extends BaseClient { class FakeMatrixApi extends BaseClient {
static String? expectedAccessToken;
static Map<String, List<dynamic>> get calledEndpoints => static Map<String, List<dynamic>> get calledEndpoints =>
currentApi!._calledEndpoints; currentApi!._calledEndpoints;
static int get eventCounter => currentApi!._eventCounter; static int get eventCounter => currentApi!._eventCounter;
@ -129,6 +131,23 @@ class FakeMatrixApi extends BaseClient {
'<html><head></head><body>Not found...</body></html>', 404); '<html><head></head><body>Not found...</body></html>', 404);
} }
if (!{
'/client/v3/refresh',
'/client/v3/login',
'/client/v3/register',
}.contains(action) &&
expectedAccessToken != null &&
request.headers['Authorization'] != 'Bearer $expectedAccessToken') {
return Response(
jsonEncode({
'errcode': 'M_UNKNOWN_TOKEN',
'error': 'Soft logged out',
'soft_logout': true,
}),
401,
);
}
// Call API // Call API
(_calledEndpoints[action] ??= <dynamic>[]).add(data); (_calledEndpoints[action] ??= <dynamic>[]).add(data);
final act = api[method]?[action]; final act = api[method]?[action];
@ -2013,6 +2032,11 @@ class FakeMatrixApi extends BaseClient {
}, },
}, },
'POST': { 'POST': {
'/client/v3/refresh': (var req) => {
'access_token': 'a_new_token',
'expires_in_ms': 60000,
'refresh_token': 'another_new_token'
},
'/client/v3/delete_devices': (var req) => {}, '/client/v3/delete_devices': (var req) => {},
'/client/v3/account/3pid/add': (var req) => {}, '/client/v3/account/3pid/add': (var req) => {},
'/client/v3/account/3pid/bind': (var req) => {}, '/client/v3/account/3pid/bind': (var req) => {},
@ -2397,6 +2421,7 @@ class FakeMatrixApi extends BaseClient {
'/client/v3/login': (var req) => { '/client/v3/login': (var req) => {
'user_id': '@test:fakeServer.notExisting', 'user_id': '@test:fakeServer.notExisting',
'access_token': 'abc123', 'access_token': 'abc123',
'refresh_token': 'refresh_abc123',
'device_id': 'GHTYAJCE', 'device_id': 'GHTYAJCE',
'well_known': { 'well_known': {
'm.homeserver': {'base_url': 'https://example.org'}, 'm.homeserver': {'base_url': 'https://example.org'},

View File

@ -32,6 +32,7 @@ void main() {
'testclient', 'testclient',
'https://example.org', 'https://example.org',
'blubb', 'blubb',
null,
'@test:example.org', '@test:example.org',
null, null,
null, null,