瀏覽代碼

Merge pull request #142 from ente-io/sessions

Sessions - view and terminate
Vishnu Mohandas 3 年之前
父節點
當前提交
aa89a39fab
共有 4 個文件被更改,包括 473 次插入75 次删除
  1. 140 0
      lib/models/sessions.dart
  2. 35 0
      lib/services/user_service.dart
  3. 194 0
      lib/ui/sessions_page.dart
  4. 104 75
      lib/ui/settings/security_section_widget.dart

+ 140 - 0
lib/models/sessions.dart

@@ -0,0 +1,140 @@
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+
+class Sessions {
+  final List<Session> sessions;
+
+  Sessions(
+    this.sessions,
+  );
+
+  Sessions copyWith({
+    List<Session> sessions,
+  }) {
+    return Sessions(
+      sessions ?? this.sessions,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'sessions': sessions?.map((x) => x.toMap())?.toList(),
+    };
+  }
+
+  factory Sessions.fromMap(Map<String, dynamic> map) {
+    return Sessions(
+      List<Session>.from(map['sessions']?.map((x) => Session.fromMap(x))),
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory Sessions.fromJson(String source) =>
+      Sessions.fromMap(json.decode(source));
+
+  @override
+  String toString() => 'Sessions(sessions: $sessions)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is Sessions && listEquals(other.sessions, sessions);
+  }
+
+  @override
+  int get hashCode => sessions.hashCode;
+}
+
+class Session {
+  final String token;
+  final int creationTime;
+  final String ip;
+  final String ua;
+  final String prettyUA;
+  final int lastUsedTime;
+
+  Session(
+    this.token,
+    this.creationTime,
+    this.ip,
+    this.ua,
+    this.prettyUA,
+    this.lastUsedTime,
+  );
+
+  Session copyWith({
+    String token,
+    int creationTime,
+    String ip,
+    String ua,
+    String prettyUA,
+    int lastUsedTime,
+  }) {
+    return Session(
+      token ?? this.token,
+      creationTime ?? this.creationTime,
+      ip ?? this.ip,
+      ua ?? this.ua,
+      prettyUA ?? this.prettyUA,
+      lastUsedTime ?? this.lastUsedTime,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'token': token,
+      'creationTime': creationTime,
+      'ip': ip,
+      'ua': ua,
+      'prettyUA': prettyUA,
+      'lastUsedTime': lastUsedTime,
+    };
+  }
+
+  factory Session.fromMap(Map<String, dynamic> map) {
+    return Session(
+      map['token'],
+      map['creationTime'],
+      map['ip'],
+      map['ua'],
+      map['prettyUA'],
+      map['lastUsedTime'],
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory Session.fromJson(String source) =>
+      Session.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'Session(token: $token, creationTime: $creationTime, ip: $ip, ua: $ua, prettyUA: $prettyUA, lastUsedTime: $lastUsedTime)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is Session &&
+        other.token == token &&
+        other.creationTime == creationTime &&
+        other.ip == ip &&
+        other.ua == ua &&
+        other.prettyUA == prettyUA &&
+        other.lastUsedTime == lastUsedTime;
+  }
+
+  @override
+  int get hashCode {
+    return token.hashCode ^
+        creationTime.hashCode ^
+        ip.hashCode ^
+        ua.hashCode ^
+        prettyUA.hashCode ^
+        lastUsedTime.hashCode;
+  }
+}

+ 35 - 0
lib/services/user_service.dart

@@ -15,6 +15,7 @@ import 'package:photos/events/user_details_changed_event.dart';
 import 'package:photos/models/key_attributes.dart';
 import 'package:photos/models/key_attributes.dart';
 import 'package:photos/models/key_gen_result.dart';
 import 'package:photos/models/key_gen_result.dart';
 import 'package:photos/models/public_key.dart';
 import 'package:photos/models/public_key.dart';
+import 'package:photos/models/sessions.dart';
 import 'package:photos/models/set_keys_request.dart';
 import 'package:photos/models/set_keys_request.dart';
 import 'package:photos/models/set_recovery_key_request.dart';
 import 'package:photos/models/set_recovery_key_request.dart';
 import 'package:photos/models/user_details.dart';
 import 'package:photos/models/user_details.dart';
@@ -121,6 +122,40 @@ class UserService {
     }
     }
   }
   }
 
 
+  Future<Sessions> getActiveSessions() async {
+    try {
+      final response = await _dio.get(
+        _config.getHttpEndpoint() + "/users/sessions",
+        options: Options(
+          headers: {
+            "X-Auth-Token": _config.getToken(),
+          },
+        ),
+      );
+      return Sessions.fromMap(response.data);
+    } on DioError catch (e) {
+      _logger.info(e);
+      rethrow;
+    }
+  }
+
+  Future<void> terminateSession(String token) async {
+    try {
+      await _dio.delete(_config.getHttpEndpoint() + "/users/session",
+          options: Options(
+            headers: {
+              "X-Auth-Token": _config.getToken(),
+            },
+          ),
+          queryParameters: {
+            "token": token,
+          });
+    } on DioError catch (e) {
+      _logger.info(e);
+      rethrow;
+    }
+  }
+
   Future<void> logout(BuildContext context) async {
   Future<void> logout(BuildContext context) async {
     final dialog = createProgressDialog(context, "logging out...");
     final dialog = createProgressDialog(context, "logging out...");
     await dialog.show();
     await dialog.show();

+ 194 - 0
lib/ui/sessions_page.dart

@@ -0,0 +1,194 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+import 'package:photos/core/configuration.dart';
+import 'package:photos/models/sessions.dart';
+import 'package:photos/services/user_service.dart';
+import 'package:photos/ui/loading_widget.dart';
+import 'package:photos/utils/date_time_util.dart';
+import 'package:photos/utils/dialog_util.dart';
+
+class SessionsPage extends StatefulWidget {
+  SessionsPage({Key key}) : super(key: key);
+
+  @override
+  _SessionsPageState createState() => _SessionsPageState();
+}
+
+class _SessionsPageState extends State<SessionsPage> {
+  Sessions _sessions;
+
+  @override
+  void initState() {
+    _fetchActiveSessions();
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: Text("active sessions"),
+      ),
+      body: _getBody(),
+    );
+  }
+
+  Widget _getBody() {
+    if (_sessions == null) {
+      return Center(child: loadWidget);
+    }
+    List<Widget> rows = [];
+    for (final session in _sessions.sessions) {
+      rows.add(_getSessionWidget(session));
+    }
+    return SingleChildScrollView(
+      child: Column(
+        children: rows,
+      ),
+    );
+  }
+
+  Widget _getSessionWidget(Session session) {
+    final lastUsedTime =
+        DateTime.fromMicrosecondsSinceEpoch(session.lastUsedTime);
+    return Column(
+      children: [
+        InkWell(
+          onTap: () async {
+            _showSessionTerminationDialog(session);
+          },
+          child: Padding(
+            padding: const EdgeInsets.all(16),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                _getUAWidget(session),
+                Padding(padding: EdgeInsets.all(4)),
+                Row(
+                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                  children: [
+                    Text(
+                      session.ip,
+                      style: TextStyle(
+                        color: Colors.white.withOpacity(0.8),
+                        fontSize: 14,
+                      ),
+                    ),
+                    Text(
+                      getFormattedTime(lastUsedTime),
+                      style: TextStyle(
+                        color: Colors.white.withOpacity(0.8),
+                        fontSize: 12,
+                      ),
+                    ),
+                  ],
+                ),
+              ],
+            ),
+          ),
+        ),
+        Divider(),
+      ],
+    );
+  }
+
+  Future<void> _terminateSession(Session session) async {
+    final dialog = createProgressDialog(context, "please wait...");
+    await dialog.show();
+    await UserService.instance.terminateSession(session.token);
+    await _fetchActiveSessions();
+    await dialog.hide();
+  }
+
+  Future<void> _fetchActiveSessions() async {
+    _sessions = await UserService.instance.getActiveSessions();
+    _sessions.sessions.sort((first, second) {
+      return second.lastUsedTime.compareTo(first.lastUsedTime);
+    });
+    setState(() {});
+  }
+
+  void _showSessionTerminationDialog(Session session) {
+    final isLoggingOutFromThisDevice =
+        session.token == Configuration.instance.getToken();
+    Widget text;
+    if (isLoggingOutFromThisDevice) {
+      text = Text(
+        "this will log you out of this device!",
+      );
+    } else {
+      text = SingleChildScrollView(
+        child: Column(
+          children: [
+            Text(
+              "this will log you out of the following device:",
+            ),
+            Padding(padding: EdgeInsets.all(8)),
+            Text(
+              session.ua,
+              style: TextStyle(
+                color: Colors.white.withOpacity(0.7),
+                fontSize: 14,
+              ),
+            ),
+          ],
+        ),
+      );
+    }
+    AlertDialog alert = AlertDialog(
+      title: Text("terminate session?"),
+      content: text,
+      actions: [
+        TextButton(
+          child: Text(
+            "terminate",
+            style: TextStyle(
+              color: Colors.red,
+            ),
+          ),
+          onPressed: () async {
+            Navigator.of(context, rootNavigator: true).pop('dialog');
+            if (isLoggingOutFromThisDevice) {
+              await UserService.instance.logout(context);
+            } else {
+              _terminateSession(session);
+            }
+          },
+        ),
+        TextButton(
+          child: Text(
+            "cancel",
+            style: TextStyle(
+              color: isLoggingOutFromThisDevice
+                  ? Theme.of(context).buttonColor
+                  : Colors.white,
+            ),
+          ),
+          onPressed: () {
+            Navigator.of(context, rootNavigator: true).pop('dialog');
+          },
+        ),
+      ],
+    );
+
+    showDialog(
+      context: context,
+      builder: (BuildContext context) {
+        return alert;
+      },
+    );
+  }
+
+  Widget _getUAWidget(Session session) {
+    if (session.token == Configuration.instance.getToken()) {
+      return Text(
+        "this device",
+        style: TextStyle(
+          fontWeight: FontWeight.bold,
+          color: Theme.of(context).buttonColor,
+        ),
+      );
+    }
+    return Text(session.prettyUA);
+  }
+}

+ 104 - 75
lib/ui/settings/security_section_widget.dart

@@ -9,7 +9,9 @@ import 'package:photos/events/two_factor_status_change_event.dart';
 import 'package:photos/services/user_service.dart';
 import 'package:photos/services/user_service.dart';
 import 'package:photos/ui/app_lock.dart';
 import 'package:photos/ui/app_lock.dart';
 import 'package:photos/ui/loading_widget.dart';
 import 'package:photos/ui/loading_widget.dart';
+import 'package:photos/ui/sessions_page.dart';
 import 'package:photos/ui/settings/settings_section_title.dart';
 import 'package:photos/ui/settings/settings_section_title.dart';
+import 'package:photos/ui/settings/settings_text_item.dart';
 import 'package:photos/utils/auth_util.dart';
 import 'package:photos/utils/auth_util.dart';
 import 'package:photos/utils/toast_util.dart';
 import 'package:photos/utils/toast_util.dart';
 
 
@@ -128,86 +130,113 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
       ),
       ),
     ]);
     ]);
     if (Platform.isAndroid) {
     if (Platform.isAndroid) {
-      children.addAll([
-        Padding(padding: EdgeInsets.all(4)),
-        Divider(height: 4),
-        Padding(padding: EdgeInsets.all(4)),
-        SizedBox(
-          height: 36,
-          child: Row(
-            mainAxisAlignment: MainAxisAlignment.spaceBetween,
-            children: [
-              Text("hide from recents"),
-              Switch(
-                value: _config.shouldHideFromRecents(),
-                onChanged: (value) async {
-                  if (value) {
-                    AlertDialog alert = AlertDialog(
-                      title: Text("hide from recents?"),
-                      content: SingleChildScrollView(
-                        child: Column(
-                          mainAxisAlignment: MainAxisAlignment.start,
-                          crossAxisAlignment: CrossAxisAlignment.start,
-                          children: const [
-                            Text(
-                              "hiding from the task switcher will prevent you from taking screenshots in this app.",
-                              style: TextStyle(
-                                height: 1.5,
+      children.addAll(
+        [
+          Padding(padding: EdgeInsets.all(4)),
+          Divider(height: 4),
+          Padding(padding: EdgeInsets.all(4)),
+          SizedBox(
+            height: 36,
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Text("hide from recents"),
+                Switch(
+                  value: _config.shouldHideFromRecents(),
+                  onChanged: (value) async {
+                    if (value) {
+                      AlertDialog alert = AlertDialog(
+                        title: Text("hide from recents?"),
+                        content: SingleChildScrollView(
+                          child: Column(
+                            mainAxisAlignment: MainAxisAlignment.start,
+                            crossAxisAlignment: CrossAxisAlignment.start,
+                            children: const [
+                              Text(
+                                "hiding from the task switcher will prevent you from taking screenshots in this app.",
+                                style: TextStyle(
+                                  height: 1.5,
+                                ),
                               ),
                               ),
-                            ),
-                            Padding(padding: EdgeInsets.all(8)),
-                            Text(
-                              "are you sure?",
-                              style: TextStyle(
-                                height: 1.5,
+                              Padding(padding: EdgeInsets.all(8)),
+                              Text(
+                                "are you sure?",
+                                style: TextStyle(
+                                  height: 1.5,
+                                ),
                               ),
                               ),
-                            ),
-                          ],
-                        ),
-                      ),
-                      actions: [
-                        TextButton(
-                          child:
-                              Text("no", style: TextStyle(color: Colors.white)),
-                          onPressed: () {
-                            Navigator.of(context, rootNavigator: true)
-                                .pop('dialog');
-                          },
-                        ),
-                        TextButton(
-                          child: Text("yes",
-                              style: TextStyle(
-                                  color: Colors.white.withOpacity(0.8))),
-                          onPressed: () async {
-                            Navigator.of(context, rootNavigator: true)
-                                .pop('dialog');
-                            await _config.setShouldHideFromRecents(true);
-                            await FlutterWindowManager.addFlags(
-                                FlutterWindowManager.FLAG_SECURE);
-                            setState(() {});
-                          },
+                            ],
+                          ),
                         ),
                         ),
-                      ],
-                    );
+                        actions: [
+                          TextButton(
+                            child: Text("no",
+                                style: TextStyle(color: Colors.white)),
+                            onPressed: () {
+                              Navigator.of(context, rootNavigator: true)
+                                  .pop('dialog');
+                            },
+                          ),
+                          TextButton(
+                            child: Text("yes",
+                                style: TextStyle(
+                                    color: Colors.white.withOpacity(0.8))),
+                            onPressed: () async {
+                              Navigator.of(context, rootNavigator: true)
+                                  .pop('dialog');
+                              await _config.setShouldHideFromRecents(true);
+                              await FlutterWindowManager.addFlags(
+                                  FlutterWindowManager.FLAG_SECURE);
+                              setState(() {});
+                            },
+                          ),
+                        ],
+                      );
 
 
-                    showDialog(
-                      context: context,
-                      builder: (BuildContext context) {
-                        return alert;
-                      },
-                    );
-                  } else {
-                    await _config.setShouldHideFromRecents(false);
-                    await FlutterWindowManager.clearFlags(
-                        FlutterWindowManager.FLAG_SECURE);
-                    setState(() {});
-                  }
-                },
-              ),
-            ],
+                      showDialog(
+                        context: context,
+                        builder: (BuildContext context) {
+                          return alert;
+                        },
+                      );
+                    } else {
+                      await _config.setShouldHideFromRecents(false);
+                      await FlutterWindowManager.clearFlags(
+                          FlutterWindowManager.FLAG_SECURE);
+                      setState(() {});
+                    }
+                  },
+                ),
+              ],
+            ),
           ),
           ),
-        ),
-      ]);
+          Padding(padding: EdgeInsets.all(4)),
+          Divider(height: 4),
+          Padding(padding: EdgeInsets.all(2)),
+          GestureDetector(
+            behavior: HitTestBehavior.translucent,
+            onTap: () async {
+              AppLock.of(context).setEnabled(false);
+              final result = await requestAuthentication();
+              AppLock.of(context)
+                  .setEnabled(Configuration.instance.shouldShowLockScreen());
+              if (!result) {
+                showToast("please authenticate to view your active sessions");
+                return;
+              }
+              Navigator.of(context).push(
+                MaterialPageRoute(
+                  builder: (BuildContext context) {
+                    return SessionsPage();
+                  },
+                ),
+              );
+            },
+            child: SettingsTextItem(
+                text: "active sessions", icon: Icons.navigate_next),
+          ),
+        ],
+      );
     }
     }
     return Column(
     return Column(
       children: children,
       children: children,