Skip to content

Commit

Permalink
feat(auth): Add support for signing out user from single device. (ser…
Browse files Browse the repository at this point in the history
  • Loading branch information
klkucaj authored Oct 31, 2024
1 parent 81ca663 commit 7f8af5d
Show file tree
Hide file tree
Showing 44 changed files with 2,887 additions and 24 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/dart-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,11 @@ jobs:
- name: Run unit tests
run: dart test --concurrency=1
working-directory: ${{ matrix.path }}

integration_test_flutter:
name: Serverpod integration tests (flutter)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run integration tests in Docker
run: util/run_tests_flutter_integration
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class AccountPage extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: () {
sessionManager.signOut();
sessionManager.signOutDevice();
},
child: const Text('Sign out'),
),
Expand Down
2 changes: 1 addition & 1 deletion examples/chat/chat_flutter/lib/src/main_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,6 @@ class _ChannelDrawer extends StatelessWidget {
}

void _signOut() {
sessionManager.signOut();
sessionManager.signOutDevice();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,31 @@ class EndpointStatus extends _i1.EndpointRef {
{},
);

/// Signs out a user.
/// **[Deprecated]** Signs out a user from all devices.
/// Use `signOutDevice` to sign out a single device
/// or `signOutAllDevices` to sign out all devices.
@Deprecated(
'Use signOutDevice to sign out a single device or signOutAllDevices to sign out all devices. This method will be removed in future releases.')
_i2.Future<void> signOut() => caller.callServerEndpoint<void>(
'serverpod_auth.status',
'signOut',
{},
);

/// Signs out a user from the current device.
_i2.Future<void> signOutDevice() => caller.callServerEndpoint<void>(
'serverpod_auth.status',
'signOutDevice',
{},
);

/// Signs out a user from all active devices.
_i2.Future<void> signOutAllDevices() => caller.callServerEndpoint<void>(
'serverpod_auth.status',
'signOutAllDevices',
{},
);

/// Gets the [UserInfo] for a signed in user, or null if the user is currently
/// not signed in with the server.
_i2.Future<_i3.UserInfo?> getUserInfo() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
include: package:serverpod_lints/public.yaml

include: package:serverpod_lints/public.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ type: module
nickname: auth

client_package_path: ../serverpod_auth_client
server_test_tools_path: test/integration/test_tools
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
test:
database: 'password'
redis: 'password'
serviceSecret: 'super_SECRET_password'
28 changes: 28 additions & 0 deletions modules/serverpod_auth/serverpod_auth_server/config/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
apiServer:
port: 8080
publicHost: serverpod_test_server
publicPort: 8080
publicScheme: http

insightsServer:
port: 8081
publicHost: serverpod_test_server
publicPort: 8081
publicScheme: http

webServer:
port: 8082
publicHost: serverpod_test_server
publicPort: 8082
publicScheme: http

database:
host: postgres
port: 5432
name: serverpod_test
user: postgres

redis:
enabled: true
host: redis
port: 6379
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Future<AuthenticationInfo?> authenticationHandler(
enableLogging: false,
);

var authKey = await tempSession.db.findById<AuthKey>(keyId);
var authKey = await AuthKey.db.findById(tempSession, keyId);
await tempSession.close();

if (authKey == null) return null;
Expand All @@ -41,7 +41,11 @@ Future<AuthenticationInfo?> authenticationHandler(
for (var scopeName in authKey.scopeNames) {
scopes.add(Scope(scopeName));
}
return AuthenticationInfo(authKey.userId, scopes);
return AuthenticationInfo(
authKey.userId,
scopes,
authId: keyIdStr,
);
} catch (exception, stackTrace) {
stderr.writeln('Failed authentication: $exception');
stderr.writeln('$stackTrace');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ typedef PasswordHashValidator = Future<bool> Function(
void Function(Object e)? onError,
});

/// Enum to define the sign-out behavior for the legacy sign out endpoint.
enum SignOutBehavior {
/// Sign out the user from all active devices.
allDevices,

/// Sign out the user from the current device only.
currentDevice,
}

/// Configuration options for the Auth module.
class AuthConfig {
static AuthConfig _config = AuthConfig();
Expand Down Expand Up @@ -157,6 +166,12 @@ class AuthConfig {
/// Create a custom validation for the password in combinaison with [PasswordHashGenerator]
final PasswordHashValidator passwordHashValidator;

/// Defines the legacy sign-out behavior for users.
///
/// - [SignOutBehavior.allDevices]: Users will be signed out from all active devices.
/// - [SignOutBehavior.currentDevice]: Users will be signed out from the current device only.
final SignOutBehavior legacyUserSignOutBehavior;

/// Creates a new Auth configuration. Use the [set] method to replace the
/// default settings. Defaults to `config/firebase_service_account_key.json`.
AuthConfig({
Expand Down Expand Up @@ -189,6 +204,7 @@ class AuthConfig {
this.allowUnsecureRandom = false,
this.passwordHashGenerator = defaultGeneratePasswordHash,
this.passwordHashValidator = defaultValidatePasswordHash,
this.legacyUserSignOutBehavior = SignOutBehavior.allDevices,
}) {
if (validationCodeLength < 8) {
stderr.writeln(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ import 'package:serverpod_auth_server/module.dart';
import 'package:serverpod_auth_server/src/business/authentication_util.dart';
import 'package:serverpod_shared/serverpod_shared.dart';

/// Collects methods for authenticating users.
/// Provides methods for authenticating users.
class UserAuthentication {
/// Signs in an user to the server. The user should have been authenticated
/// before signing them in. Send the AuthKey.id and key to the client and
/// use that to authenticate in future calls. In most situations you should
/// use one of the auth providers instead of this method.
///
/// - `updateSession`: If set to `true`, the session will be updated with
/// the authenticated user's information. The default is `true`.
static Future<AuthKey> signInUser(
Session session,
int userId,
String method, {
Set<Scope> scopes = const {},
bool updateSession = true,
}) async {
var signInSalt = session.passwords['authKeySalt'] ?? defaultAuthKeySalt;

var key = generateRandomString();
var hash = hashString(signInSalt, key);

Expand All @@ -34,21 +37,91 @@ class UserAuthentication {
method: method,
);

session.updateAuthenticated(AuthenticationInfo(userId, scopes));
var result = await AuthKey.db.insertRow(session, authKey);
return result.copyWith(key: key);

if (updateSession) {
session.updateAuthenticated(
AuthenticationInfo(
userId,
scopes,
authId: '${result.id}',
),
);
}
return result;
}

/// Signs out a user from the server and deletes all authentication keys.
/// This means that the user will be signed out from all connected devices.
static Future<void> signOutUser(Session session, {int? userId}) async {
/// If the user being signed out is the currently authenticated user, the
/// session's authentication information will be cleared.
///
/// Note: The method will fail silently if no authentication information is
/// found for the user.
static Future<void> signOutUser(
Session session, {
int? userId,
}) async {
userId ??= (await session.authenticated)?.userId;
if (userId == null) return;

await session.db
.deleteWhere<AuthKey>(where: AuthKey.t.userId.equals(userId));
await session.messages
.authenticationRevoked(userId, RevokedAuthenticationUser());
session.updateAuthenticated(null);
// Delete all authentication keys for the user
var auths = await AuthKey.db.deleteWhere(
session,
where: (row) => row.userId.equals(userId),
);

if (auths.isEmpty) return;

// Notify clients about the revoked authentication for the user
await session.messages.authenticationRevoked(
userId,
RevokedAuthenticationUser(),
);

// Clear session authentication if the signed-out user is the currently
// authenticated user
var authInfo = await session.authenticated;
if (userId == authInfo?.userId) {
session.updateAuthenticated(null);
}
}

/// Signs out the user from the current device by deleting the specific
/// authentication key. This does not affect the user's sessions on other
/// devices. If the user being signed out is the currently authenticated user,
/// the session's authentication information will be cleared.
///
/// Note: The method will fail silently if no authentication information is
/// found for the key.
static Future<void> revokeAuthKey(
Session session, {
required String authKeyId,
}) async {
int? id = int.tryParse(authKeyId);
if (id == null) return;

// Delete the authentication key for the current device
var auths = await AuthKey.db.deleteWhere(
session,
where: (row) => row.id.equals(id),
);

if (auths.isEmpty) return;
var auth = auths.first;

// Notify the client about the revoked authentication for the specific
// auth key
await session.messages.authenticationRevoked(
auth.userId,
RevokedAuthenticationAuthId(authId: authKeyId),
);

// Clear session authentication if the signed-out user is the currently
// authenticated user
var authInfo = await session.authenticated;
if (auth.userId == authInfo?.userId) {
session.updateAuthenticated(null);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ class Users {
await session.db.updateRow(userInfo);
await invalidateCacheForUser(session, userId);
// Sign out user
await UserAuthentication.signOutUser(session, userId: userId);
await UserAuthentication.signOutUser(
session,
userId: userId,
);
}

/// Unblocks a user so that they can log in again.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,64 @@ class StatusEndpoint extends Endpoint {
return userId != null;
}

/// Signs out a user.
/// **[Deprecated]** Signs out a user from all devices.
/// Use `signOutDevice` to sign out a single device
/// or `signOutAllDevices` to sign out all devices.
@Deprecated(
'Use signOutDevice to sign out a single device or signOutAllDevices to sign out all devices. '
'This method will be removed in future releases.',
)
Future<void> signOut(Session session) async {
await UserAuthentication.signOutUser(session);
var authInfo = await session.authenticated;
if (authInfo == null) return;

switch (AuthConfig.current.legacyUserSignOutBehavior) {
case SignOutBehavior.currentDevice:
var authKeyId = authInfo.authId;
if (authKeyId == null) return;

return UserAuthentication.revokeAuthKey(
session,
authKeyId: authKeyId,
);
case SignOutBehavior.allDevices:
return UserAuthentication.signOutUser(
session,
userId: authInfo.userId,
);
}
}

/// Signs out a user from the current device.
Future<void> signOutDevice(Session session) async {
var authInfo = await session.authenticated;
var authKeyId = authInfo?.authId;
if (authKeyId == null) return;

return UserAuthentication.revokeAuthKey(
session,
authKeyId: authKeyId,
);
}

/// Signs out a user from all active devices.
Future<void> signOutAllDevices(Session session) async {
var authInfo = await session.authenticated;
var userId = authInfo?.userId;
if (userId == null) return;

return UserAuthentication.signOutUser(
session,
userId: userId,
);
}

/// Gets the [UserInfo] for a signed in user, or null if the user is currently
/// not signed in with the server.
Future<UserInfo?> getUserInfo(Session session) async {
var userId = (await session.authenticated)?.userId;
if (userId == null) return null;

return await UserInfo.db.findById(session, userId);
}

Expand Down
Loading

0 comments on commit 7f8af5d

Please sign in to comment.