双token机制:flutter_secure_storage 实现加密存储

1. 双Token机制

json 复制代码
{

  "code": 0,

  "data": {

    "userId": 305,

    "accessToken": "1c56f701051547bca956140f6c73a189",

    "refreshToken": "3a9b5ac0eeed4d93ae67d3d0b0fb8d42",

    "expiresTime": 1757122540160,

    "openid": null

  },

  "msg": ""

}

作为一个初学者,我知道在发送网络请求中,我们需要在请求头中携带一个Token身份令牌;当接触到真实的后端接口后,为什么会有两个Token呢? 这种设计模式被称为 "访问令牌 + 刷新令牌"机制。

java 复制代码
1. Access Token (访问令牌)
是什么:就像一把一次性的门禁卡或电影票。
用途:客户端(Flutter App) 用它来访问受保护的资源(比如获取用户信息、发布内容、访问付费 API 等)。每次向服务器请求数据时,都需要在 HTTP Header(通常是 Authorization: Bearer <access_token>)中带上它,服务器会验证这个令牌是否有效且有权访问所求资源。
特点:
生命周期短:从响应数据中 "expiresTime":1757040344156 可以看出,这个令牌有一个明确的过期时间(可能几小时或几天后)。这是一个时间戳,对应北京时间 2025-09-05 10:45:44。
权限高:直接代表着用户的授权。
暴露风险高:因为它经常在网络请求中传输。

2. Refresh Token (刷新令牌)
是什么:就像一把用来补办门禁卡的钥匙或你的身份证。
用途:唯一的目的就是在 Access Token 过期后,用它去申请一个新的 Access Token。它本身不能用来访问任何业务 API。
特点:
生命周期长:它的有效期比 Access Token 长得多(可能是几周、几个月,或者直到用户主动注销)。
权限低:它只能用来换新的 Access Token,不能做别的事。
暴露风险低:它只会在一种非常特定的请求(刷新令牌)中发送,传输频率极低。

设计优点:

java 复制代码
1. 安全性 (Security)
想象一下,如果只有一个长期有效的 Access Token:
一旦这个 Token 被黑客拦截或窃取(比如通过不安全的网络、日志泄露等),黑客就可以永久地冒充用户,为所欲为,因为令牌永远不会失效。
而使用双 Token 机制:
即使 Access Token 被窃取,因为它有效期很短,黑客能作恶的时间窗口也非常有限(可能只剩几个小时)。
Refresh Token 很少在网络上传送,所以被窃取的风险要低得多。即使 Access Token 泄露,攻击者也无法获取新的 Token,因为刷新需要 Refresh Token。
服务器可以有一个黑名单机制。如果发现异常行为(比如一个 Refresh Token 在短时间内从两个不同国家请求新 Access Token),服务器可以立即让这个 Refresh Token 失效,从而保护用户账户。这比撤销一个长期有效的 Access Token 要容易和高效得多。

2. 用户体验 (User Experience)
如果 Access Token 过期后,只让用户重新登录:
用户可能正在使用 App,突然就被踢了出去,需要重新输入用户名和密码,体验非常差。
而有了 Refresh Token:
应用可以在用户无感知的情况下(静默地)用 Refresh Token 去获取一个新的 Access Token,然后继续之前的操作。
用户只有在 Refresh Token 也过期(或者被服务器主动撤销)时,才需要重新登录。这大大延长了用户的"登录会话"时间,提升了体验。

然后将怎么使用呢?

我们把Token的过期时间也保存下来,每次打开应用后对比当前时间戳与过期的时间戳(当前时间戳 > 存储的时间戳)说明Token过期,请求刷新Token接口,传入刷新令牌(当然在过期之间刷新)。

之后我们将要考虑存储的安全性,我来学习一下flutter_secure_storage加密存储插件看看怎么使用。

2. flutter_secure_storage加密存储

以下内容还未来得及尝试:

1. 添加依赖

pubspec.yaml 文件中添加依赖:

dart 复制代码
dependencies:
  flutter_secure_storage: ^8.0.0

运行 flutter pub get 安装包。

2. Android 配置

android/app/build.gradle 文件中,确保 minSdkVersion 至少为 18:

gradle 复制代码
android {
    defaultConfig {
        minSdkVersion 18 // 或更高版本
        // ... 其他配置
    }
}

3. 基本使用方法

导入包

dart 复制代码
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

创建存储实例(针对 Android 和 iOS 优化)

dart 复制代码
// 创建适用于 Android 和 iOS 的存储实例
final storage = FlutterSecureStorage(
  aOptions: const AndroidOptions(
    encryptedSharedPreferences: true, // 在 Android 上使用更安全的EncryptedSharedPreferences
  ),
  iOptions: const IOSOptions(
    accessibility: KeychainAccessibility.first_unlock, // 设备首次解锁后可用
  ),
);

常用操作示例

dart 复制代码
// 写入数据
await storage.write(key: 'access_token', value: 'your_token_here');
await storage.write(key: 'user_id', value: '12345');
// 读取数据
String? token = await storage.read(key: 'access_token');
String? userId = await storage.read(key: 'user_id');
// 读取所有值
Map<String, String> allValues = await storage.readAll();
// 删除单个值
await storage.delete(key: 'access_token');
// 删除所有值
await storage.deleteAll();
// 检查键是否存在
bool exists = await storage.containsKey(key: 'access_token');

4. 封装成工具类(推荐)

为了更好地组织代码,建议创建一个工具类:

dart 复制代码
class SecureStorage {
  static final SecureStorage _instance = SecureStorage._internal();
  factory SecureStorage() => _instance;
  SecureStorage._internal();

  final FlutterSecureStorage _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  // 存储访问令牌
  Future<void> saveAccessToken(String token) async {
    await _storage.write(key: 'access_token', value: token);
  }

  // 获取访问令牌
  Future<String?> getAccessToken() async {
    return await _storage.read(key: 'access_token');
  }

  // 存储刷新令牌
  Future<void> saveRefreshToken(String token) async {
    await _storage.write(key: 'refresh_token', value: token);
  }

  // 获取刷新令牌
  Future<String?> getRefreshToken() async {
    return await _storage.read(key: 'refresh_token');
  }

  // 清除所有存储的数据
  Future<void> clearAll() async {
    await _storage.deleteAll();
  }

  // 检查用户是否已登录(是否有访问令牌)
  Future<bool> isLoggedIn() async {
    return await _storage.containsKey(key: 'access_token');
  }
}

5. 在应用中使用

dart 复制代码
// 在登录成功后保存令牌
void onLoginSuccess(String accessToken, String refreshToken) async {
  final secureStorage = SecureStorage();
  await secureStorage.saveAccessToken(accessToken);
  await secureStorage.saveRefreshToken(refreshToken);
}

// 在应用启动时检查登录状态
void checkLoginStatus() async {
  final secureStorage = SecureStorage();
  bool isLoggedIn = await secureStorage.isLoggedIn();
  
  if (isLoggedIn) {
    String? token = await secureStorage.getAccessToken();
    // 使用令牌进行API调用
  } else {
    // 导航到登录页面
  }
}

// 登出时清除数据
void onLogout() async {
  final secureStorage = SecureStorage();
  await secureStorage.clearAll();
}

注意事项

  1. Android 备份 :默认情况下,Android 会备份数据到 Google Drive,这可能会导致安全问题和异常。建议在 AndroidManifest.xml 中禁用自动备份或排除安全存储数据:
xml 复制代码
<application
    android:allowBackup="false"
    ...>
    <!-- 或者 -->
    <application
        android:allowBackup="true"
        android:fullBackupContent="@xml/backup_rules"
        ...>

创建 android/app/src/main/res/xml/backup_rules.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
    <exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</full-backup-content>
  1. iOS 钥匙串访问组:如果您需要在多个应用间共享钥匙串数据,可能需要配置钥匙串访问组。但对于大多数单应用场景,不需要额外配置。
  2. 错误处理:在实际应用中,应该为所有存储操作添加适当的错误处理:
dart 复制代码
try {
  await storage.write(key: 'key', value: 'value');
} catch (e) {
  print('存储数据时出错: $e');
  // 处理错误,例如使用备用存储方案
}

这样,您就可以在 AndroidiOS 应用中使用 flutter_secure_storage 安全地存储敏感数据了。这个插件会自动为每个平台使用适当的安全存储机制(Android Keystore/EncryptedSharedPreferences iOSKeychain)。

3. 真实测试

代码:

main.dart

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage_example/secure_storage.dart';

void main() {
  runApp(const MyApp());
}

///
class MyApp extends StatelessWidget {
  ///
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: StorageTestPage(),
    );
  }
}

///
class StorageTestPage extends StatefulWidget {
  ///
  const StorageTestPage({super.key});

  @override
  State<StorageTestPage> createState() => _StorageTestPageState();
}

class _StorageTestPageState extends State<StorageTestPage> {
  final SecureStorage storage = SecureStorage();

  String _accessToken = '';
  String _refreshToken = '';
  bool _isLoggedIn = false;

  final TextEditingController _accessController = TextEditingController();
  final TextEditingController _refreshController = TextEditingController();

  Future<void> _saveTokens() async {
    await storage.saveAccessToken(_accessController.text);
    await storage.saveRefreshToken(_refreshController.text);
    await _readTokens();
  }

  Future<void> _readTokens() async {
    final access = await storage.getAccessToken() ?? 'null';
    final refresh = await storage.getRefreshToken() ?? 'null';
    final loggedIn = await storage.isLoggedIn();

    setState(() {
      _accessToken = access;
      _refreshToken = refresh;
      _isLoggedIn = loggedIn;
    });
  }

  Future<void> _clearTokens() async {
    await storage.clearAll();
    await _readTokens();
  }

  @override
  void initState() {
    super.initState();
    _readTokens();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('SecureStorage 测试页面')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: _accessController,
              decoration: const InputDecoration(labelText: 'Access Token'),
            ),
            TextField(
              controller: _refreshController,
              decoration: const InputDecoration(labelText: 'Refresh Token'),
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                ElevatedButton(
                  onPressed: _saveTokens,
                  child: const Text('保存'),
                ),
                ElevatedButton(
                  onPressed: _readTokens,
                  child: const Text('读取'),
                ),
                ElevatedButton(
                  onPressed: _clearTokens,
                  child: const Text('清除'),
                ),
              ],
            ),
            const Divider(height: 32),
            Text('Access Token: $_accessToken'),
            Text('Refresh Token: $_refreshToken'),
            Text('是否已登录: $_isLoggedIn'),
          ],
        ),
      ),
    );
  }
}

secure_storage.dart

dart 复制代码
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

/// 存储及工具类
class SecureStorage {
  /// 单例模式
  factory SecureStorage() => _instance;

  /// 构造函数
  SecureStorage._internal();

  static final SecureStorage _instance = SecureStorage._internal();

  final FlutterSecureStorage _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  /// 存储访问令牌
  Future<void> saveAccessToken(String token) async {
    await _storage.write(key: 'access_token', value: token);
  }

  /// 获取访问令牌
  Future<String?> getAccessToken() async {
    return _storage.read(key: 'access_token');
  }

  /// 存储刷新令牌
  Future<void> saveRefreshToken(String token) async {
    await _storage.write(key: 'refresh_token', value: token);
  }

  /// 获取刷新令牌
  Future<String?> getRefreshToken() async {
    return _storage.read(key: 'refresh_token');
  }

  /// 清除所有存储的数据
  Future<void> clearAll() async {
    await _storage.deleteAll();
  }

  /// 检查用户是否已登录(是否有访问令牌)
  Future<bool> isLoggedIn() async {
    return _storage.containsKey(key: 'access_token');
  }
}

如何在模拟器上看加密存储后的数据,在项目终端依次输入以下命令:

bash 复制代码
adb shell
run-as com.example.flutter_secure_storage_example
cd shared_prefs
cat FlutterSecureStorage.xml

然后得到

xml 复制代码
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="__androidx_security_crypto_encrypted_prefs_key_keyset__">12a701124456bc5290290e354c5a1d61088990a67618ca5eded893b2cc5a61b2cce225445547fca6e3b2d3d3591f37acb9d85b568cab9a0c35ee2dab3caa5e6ce98863e62c074cb169beac7260bfc13c66191b5a509e1862d84ce2785df93771a3641728bba67fefb4089668b9d0f74695d5e748b44bf8aecb5fea5bec3be56a22377acd903e588a1171b81b70df615a769788c6dd6120b18ffd81d32329b6e6777dcd82e9c5620643ab1a4208fb86c627123b0a30747970652e676f6f676c65617069732e636f6d2f676f6f676c652e63727970746f2e74696e6b2e4165735369764b6579100118fb86c6272001</string>
    <string name="__androidx_security_crypto_encrypted_prefs_value_keyset__">128801af0f51731b0a553507d2cc8777a4386d301275860c4821386fbee7b5c31f6750e26655fb37d1ea4a9e1beb2248c705cd8184662ff4fe9b791749b5525a6359db351f36ee8db4b54fb6c598579078425e57aec4302ba11999fd131a72f1a3cf7627070bba73ad9e88f9219b2040b1d00d51c99c891bc11b4db8a300971f2d63b862488635a18cbfc91a4408ff8087f503123c0a30747970652e676f6f676c65617069732e636f6d2f676f6f676c652e63727970746f2e74696e6b2e41657347636d4b6579100118ff8087f5032001</string>
    <string name="AQTxg3tF/FbN5ojqZADBaV4DLVEt4sJW5tfzZgKCO9zeZFkQ3en8tTs/rYf7JDwDBM5brlmGzyNVjCvL/yUCacSG9Hy6q80Fmp1pyi01EFkwYrY/sl74Ew==">AT6hwH8xjjMZjux6626D8XCiAOfbcCKiE8EUGuBovF9uvHc/uAuh4D1RR3zNxQ==</string>
    <string name="AQTxg3uwgoF/Qw+tcio/UNFY34OFIXBFBeg52C7qZ45TSSD4hgorX+gVN5mW5esTDFypJW9goK/IDxIaQbJXS9GC5+ceBFe/2spzvDW444tq4uVkA84NYMI=">AT6hwH/h+qRydKtr1IIZu0yf3L/WugEuYdod+FvMDRdarepExadPOwzsonjq9g==</string>
</map>

上边的数据构造如下:

dart 复制代码
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="__androidx_security_crypto_encrypted_prefs_key_keyset__">...</string>
    <string name="__androidx_security_crypto_encrypted_prefs_value_keyset__">...</string>
    <string name="加密后的 key">加密后的 value</string>
    <string name="加密后的 key">加密后的 value</string>
</map>

证明我们加密存储成功

相关推荐
拾光拾趣录11 分钟前
HTML | 10个常犯的错误
前端·html
coding随想13 分钟前
常见UI事件解析:Load/Unload、Error/Abort、Resize/Scroll、Select/DOMFocusIn等
前端
鹧鸪yy16 分钟前
从Token介绍到单点登录SSO
前端·javascript
青山Coding38 分钟前
Cesium应用(三):全球气压可视化与气象时序图实现方案
前端·gis·cesium
老虎06271 小时前
JavaWeb前端03(Ajax概念及在前端开发时应用)
前端·javascript·ajax
Aphasia3111 小时前
性能优化之重绘和重排
前端·面试
Python私教1 小时前
yggjs_react使用教程 v0.1.1
前端·react.js·前端框架
Jinuss1 小时前
Vue3源码reactivity响应式篇之Map、Set等代理处理详解
前端·vue.js·vue3
用纸拆浪1 小时前
❤️❤️组件踩坑日记:vxe-table-select下拉表格异步加载时的数据回显问题
前端
小鸡脚来咯1 小时前
react速成
前端·javascript·react.js