You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter Web升级空安全后刷新页面自动登出问题求助

Flutter Web升级空安全后刷新自动登出问题

我的Flutter Web应用在未升级空安全前运行正常,升级后出现刷新页面即自动登出的问题。以下是相关代码:

Auth类代码

import 'dart:convert';
import 'dart:async';
import 'package:jwt_decode/jwt_decode.dart' as Jwt;
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../main.dart';
import '../models/http_exception.dart';
class Auth with ChangeNotifier {
  String? _token;
  DateTime? _expiryDate;
  Timer? _authTimer;
  bool get isAuth {
    return token != null;
  }
  String? get token {
    if (_expiryDate != null &&
        _expiryDate!.isAfter(DateTime.now()) &&
        _token != null) {
      return _token;
    }
    return null;
  }
  Future<void> _authenticate(String email, String password) async {
    final url = (APIPROTOCOL == 'http://')
        ? APIPROTOCOL + APIHOST + '/api/token/'
        : APIPROTOCOL + APIHOST + APIHOST_PREFIX + '/api/token/';
    try {
      final response = await http.post(
        Uri.parse(url),
        headers: {"Content-Type": "application/json"},
        body: json.encode(
          {
            'username': email,
            'password': password,
          },
        ),
      );
      final responseData = json.decode(response.body);
      if (responseData['detail'] != null) {
        throw HttpException(responseData['detail']);
      }
      _token = responseData['access'];
      final parsedJwt = Jwt.Jwt.parseJwt(_token!);
      _expiryDate =
          DateTime.fromMillisecondsSinceEpoch(parsedJwt['exp'] * 1000);
      _autoLogout();
      notifyListeners();
      final prefs = await SharedPreferences.getInstance();
      final userData = json.encode(
        {
          'token': _token,
          'expiryDate': _expiryDate?.toIso8601String(),
        },
      );
      prefs.setString('userData', userData);
    } catch (error) {
      print(error);
      throw error;
    }
  }
  Future<void> signup(String email, String password) async {
    return _authenticate(email, password);
  }
  Future<void> login(String email, String password) async {
    return _authenticate(email, password);
  }
 Future<bool> tryAutoLogin() async {
    final prefs = await SharedPreferences.getInstance();
    if (!prefs.containsKey('userData')) {
      return false;
    }
    final extractedUserData = json.decode(prefs.getString('userData') as String)
        as Map<String, Object>;
    final expiryDate =
        DateTime.parse(extractedUserData['expiryDate'] as String);
    if (expiryDate.isBefore(DateTime.now())) {
      return false;
    }
    _token = extractedUserData['token'] as String;
    _expiryDate = expiryDate;
    notifyListeners();
    _autoLogout();
    return true;
  }
  Future<void> logout() async {
    _token = null;
    _expiryDate = null;
    if (_authTimer != null) {
      _authTimer!.cancel();
      _authTimer = null;
    }
    notifyListeners();
    final prefs = await SharedPreferences.getInstance();
    // prefs.remove('userData');
    prefs.clear();
  }
  void _autoLogout() {
    if (_authTimer != null) {
      _authTimer!.cancel();
    }
    final timeToExpiry = _expiryDate!.difference(DateTime.now()).inSeconds;
    _authTimer = Timer(Duration(seconds: timeToExpiry), logout);
  }
}

登录表单代码

class _SignUpFormState extends State<SignUpForm> {
  final GlobalKey<FormState> _formKey = GlobalKey();
  final _passwordTextController = TextEditingController();
  final _usernameTextController = TextEditingController();
  Map<String, String> _authData = {
    'email': '',
    'password': '',
  };
  double _formProgress = 0;
  var _isLoading = false;

  @override
  void dispose() {
    _passwordTextController.dispose();
    _usernameTextController.dispose();
    super.dispose();
  }

  void _showErrorDialog(String message) {
    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text('Achtung...'),
        content: Text(message),
        actions: <Widget>[
          TextButton(
            child: Text('Okay'),
            onPressed: () {
              Navigator.of(ctx).pop();
            },
          )
        ],
      ),
    );
  }

  Future<void> _submit() async {
    if (!_formKey.currentState!.validate()) {
      // Invalid!
      return;
    }
    _formKey.currentState?.save();
    setState(() {
      _isLoading = true;
    });
    try {
      // Log user in
      await Provider.of<Auth>(context, listen: false).login(
        _authData['email']!,
        _authData['password']!,
      );
    } on HttpException catch (error) {
      var errorMessage = error.toString();
      _showErrorDialog(errorMessage);
    } catch (error) {
      const errorMessage =
          'Wir konnten dich nicht anmelden. Bitte überprüfe deine Verbindung.';
      _showErrorDialog(errorMessage);
    }

    setState(() {
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          LinearProgressIndicator(
            value: _formProgress,
            backgroundColor: Theme.of(context).accentColor,
          ),
          SizedBox(
            height: 12,
          ),
          Text('Anmeldung', style: Theme.of(context).textTheme.headline4),
          Padding(
            padding: EdgeInsets.all(24.0),
            child: TextFormField(
              controller: _usernameTextController,
              decoration: InputDecoration(hintText: 'Email-Adresse'),
              validator: (value) {
                if (value!.isEmpty) {
                  return 'Bitte Visoon Email-Adresse eingeben';
                }
                return null;
              },
              onSaved: (value) {
                _authData['email'] = value!;
              },
            ),
          ),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 24.0),
            child: TextFormField(
              controller: _passwordTextController,
              obscureText: true,
              decoration: InputDecoration(hintText: 'Passwort'),
              validator: (value) {
                if (value!.isEmpty) {
                  return 'Bitte Passwort eingeben';
                }
                return null;
              },
              onSaved: (value) {
                _authData['password'] = value!;
              },
            ),
          ),
          SizedBox(
            height: 12,
          ),
          if (_isLoading)
            CircularProgressIndicator()
          else
            TextButton(
              style: TextButton.styleFrom(
                  backgroundColor: Theme.of(context).colorScheme.secondary,
                  primary: Colors.white),
              onPressed: _submit,
              child: Text(
                'Anmelden',
                style: TextStyle(fontSize: 18),
              ),
            ),
          SizedBox(
            height: 12,
          ),
        ],
      ),
    );
  }
}

主应用中tryAutoLogin调用代码

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider.value(
          value: Auth(),
        ),
 ],
      child: Consumer<Auth>(
        builder: (ctx, auth, _) => MaterialApp(
          scrollBehavior: CustomScrollBehavior(),
          title: 'Visoon Forecasting',
          theme: ThemeData(
            primaryColor: const Color(0xff1e1e1e),
            fontFamily: 'Futura',
            colorScheme: ColorScheme.fromSwatch()
                .copyWith(secondary: const Color(0xffe20644)),
          ),
          navigatorObservers: [routeObserver],
          home: auth.isAuth
              ? DashboardScreen()
              : FutureBuilder(
                  future: auth.tryAutoLogin(),
                  builder: (ctx, authResultSnapshot) =>
                      authResultSnapshot.connectionState ==
                              ConnectionState.waiting
                          ? SplashScreen()
                          : SignUpScreen(),
                ),

问题原因

  1. tryAutoLogin类型转换风险:强制将extractedUserData['token']和extractedUserData['expiryDate']转为非空String,若存储的数据为空安全升级前的null值,会触发类型转换错误,导致自动登录失败返回false,页面跳转至登录页。
  2. _autoLogout非空断言隐患:直接使用_expiryDate!,若_expiryDate为null(比如自动登录失败时),会抛出异常,进而触发logout方法清空状态。
  3. Provider初始化方式错误:ChangeNotifierProvider.value(value: Auth())会在Widget重建时重新创建Auth实例,Web刷新后状态丢失,结合自动登录的问题,直接回到登录状态。

修复方案

1. 安全处理自动登录的类型转换

修改tryAutoLogin方法,增加空值检查,避免强制转换失败:

Future<bool> tryAutoLogin() async {
  final prefs = await SharedPreferences.getInstance();
  if (!prefs.containsKey('userData')) {
    return false;
  }
  
  final userDataString = prefs.getString('userData');
  if (userDataString == null) {
    return false;
  }

  final extractedUserData = json.decode(userDataString) as Map<String, dynamic>;
  
  final expiryDateStr = extractedUserData['expiryDate'] as String?;
  if (expiryDateStr == null) {
    return false;
  }
  final expiryDate = DateTime.parse(expiryDateStr);
  if (expiryDate.isBefore(DateTime.now())) {
    return false;
  }

  final token = extractedUserData['token'] as String?;
  if (token == null) {
    return false;
  }
  
  _token = token;
  _expiryDate = expiryDate;
  notifyListeners();
  _autoLogout();
  return true;
}

2. 修复_autoLogout的空安全问题

增加_expiryDate空值检查,避免非空断言报错:

void _autoLogout() {
  if (_authTimer != null) {
    _authTimer!.cancel();
    _authTimer = null;
  }
  if (_expiryDate == null) return;
  
  final timeToExpiry = _expiryDate!.difference(DateTime.now()).inSeconds;
  if (timeToExpiry <= 0) {
    logout();
    return;
  }
  _authTimer = Timer(Duration(seconds: timeToExpiry), logout);
}

3. 修正Provider创建方式

使用create方法初始化Auth,确保全局仅一个实例:

MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => Auth()),
  ],
  // 剩余代码不变
)

4. 确保存储数据的非空性

在_authenticate方法中,存储用户数据时确保token和expiryDate非空:

final userData = json.encode(
  {
    'token': _token!, // 此处_token已通过JWT解析赋值,非空
    'expiryDate': _expiryDate!.toIso8601String(), // 同理_expiryDate非空
  },
);

内容的提问来源于stack exchange,提问作者Davood

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 16:50:23