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

Flutter中实现OAuth2资源所有者密码授权及多用户Token管理求助

Flutter 集成 Spring Boot OAuth2 问题求助

我正在用Flutter开发Android/iOS/Web跨端应用,后端是Spring Boot OAuth2服务器。作为Flutter新手,不知道怎么实现以下功能:

  • 实现采用password授权类型的登录界面
  • 在Access Token过期前,通过Refresh Token实现自动刷新机制
  • 实现类似Gmail的多用户登录及便捷切换的Token管理功能

我已经了解了用oauth2_client库实现客户端凭证及授权码授权的方式,但不知道怎么完成上面的需求。

以下是我的后端AuthorizationServerConfigurerAdapter配置代码:

import com.exp.myserver.model.Account;
import com.exp.myserver.model.CustomUserDetails;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.authentication.configuration.GlobalAuthenticationConfigurerAdapter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

@Configuration
@EnableAuthorizationServer
public class MyOAuthConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager;

    @Autowired
    private PasswordEncoder passwordEncoder;

    private static final String RESOURCE_ID = "restservice";

    static Logger logger = LoggerFactory.getLogger(OAuth2AuthorizationConfig.class);

    private static final int VALID_FOREVER = -1;

    // TODO externalize token related data to configuration, store clients in DB
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        logger.info(" configure(ClientDetailsServiceConfigurer clients) ");
        clients
                .inMemory()
                .withClient("clientId")
                .authorizedGrantTypes("password","refresh_token")
                .authorities("ROLE_ADMIN","ROLE_CLIENT","ROLE_SUPPORT","ROLE_MANAGER","ROLE_ACCOUNT")
                .scopes("read", "write")
                .resourceIds(RESOURCE_ID)
                .secret(passwordEncoder.encode("clientPassword")).accessTokenValiditySeconds(4800).refreshTokenValiditySeconds(VALID_FOREVER);//Integer.MAX_VALUE
    }

    /*
    * The endpoints can only be accessed by a not logged in user or a user with
    * the specified role
    */
    // TODO externalise configuration
    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
        logger.info(" configure(AuthorizationServerSecurityConfigurer oauthServer) ");
        oauthServer.tokenKeyAccess("isAnonymous() || hasAuthority('ROLE_ADMIN')")
                .checkTokenAccess("hasAuthority('ROLE_ADMIN')");
        oauthServer.allowFormAuthenticationForClients();
        oauthServer.checkTokenAccess("permitAll()");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        logger.info(" configure(AuthorizationServerEndpointsConfigurer endpoints) ");
        endpoints.tokenStore(tokenStore()).tokenEnhancer(jwtAccessTokenConverter())
                .authenticationManager(authenticationManager);
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    // TODO encrypt password
    @Bean
    protected JwtAccessTokenConverter jwtAccessTokenConverter() {
        logger.info(" jwtAccessTokenConverter() ");
        JwtAccessTokenConverter converter = new CustomTokenEnhancer();
        converter.setKeyPair(new KeyStoreKeyFactory(new ClassPathResource("keystore.jks"), "keystorePassword".toCharArray()).getKeyPair("mykeystore"));
        return converter;
    }

    /*
    * Add custom user principal information to the JWT token
    */
    // TODO additional information fields should be get from configuration
    protected static class CustomTokenEnhancer extends JwtAccessTokenConverter {
        @Override
        public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
            logger.info(" OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) ");
            Account user = (Account) authentication.getPrincipal();

            Map<String, Object> info = new LinkedHashMap<String, Object>(accessToken.getAdditionalInformation());
            
            CustomUserDetails custUser = (CustomUserDetails) authentication.getPrincipal();

            info.put("user_id", user.getId());
            info.put("email", user.getUserName());
            info.put("user_mode", user.getUserMode());
            info.put("company_id", user.getUserCompany().getCompanyId().getId());
            info.put("company_name", user.getUserCompany().getCompanyId().getName());
            DefaultOAuth2AccessToken customAccessToken = new DefaultOAuth2AccessToken(accessToken);
            // Get the authorities from the user
            Set<GrantedAuthority> authoritiesSet = new HashSet<>(authentication.getAuthorities());

            logger.info("GrantedAuthority authorities "+authentication.getAuthorities());

            // Generate String array
            String[] authorities = new String[authoritiesSet.size()];

            logger.info("authoritiesSet.size() "+authoritiesSet.size());

            int i = 0;
            for (GrantedAuthority authority : authoritiesSet)
                authorities[i++] = authority.getAuthority();

            info.put("authorities", authorities);
            customAccessToken.setAdditionalInformation(info);

            return super.enhance(customAccessToken, authentication);
        }
    }

    /*
    * Setup the refresh_token functionality to work with the custom
    * UserDetailsService
    */
    @Configuration
    protected static class GlobalAuthenticationManagerConfiguration extends GlobalAuthenticationConfigurerAdapter {

        @Autowired
        private UserDetailsService userDetailsService;

        @Autowired
        private PasswordEncoder passwordEncoder;

        @Override
        public void init(AuthenticationManagerBuilder auth) throws Exception {
        logger.info(" Oauth init(AuthenticationManagerBuilder auth) ");
            auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
            logger.info(" Oauth init(AuthenticationManagerBuilder auth) "+auth.getDefaultUserDetailsService().toString());
        }
    }
}

我用CustomTokenEnhancer在登录时给认证用户添加了自定义信息,现在已经能通过Flutter的oauth2库完成登录,但无法从OAuth客户端获取用户信息,我的Flutter代码如下:

import 'package:oauth2/oauth2.dart' as oauth2;

Future<oauth2.Client> authenticateUser() async {
    // This URL is an endpoint that's provided by the authorization server. It's
    // usually included in the server's documentation of its OAuth2 API.
    final authorizationEndpoint = Uri.parse('${selCompanyUrl}oauth/token');

    // The user should supply their own username and password.
    final uName = username;
    final uPassword = password;

    // The authorization server may issue each client a separate client
    // identifier and secret, which allows the server to tell which client
    // is accessing it. Some servers may also have an anonymous
    // identifier/secret pair that any client may use.
    //
    // Some servers don't require the client to authenticate itself, in which case
    // these should be omitted.
    final identifier = 'clientID';
    final secret = 'ClientPassword';

    // Make a request to the authorization endpoint that will produce the fully
    // authenticated Client.
    var client = await oauth2.resourceOwnerPasswordGrant(
        authorizationEndpoint, uName!, uPassword!,
        identifier: identifier, secret: secret);
    print('Oauth Client : $client :: access token : ${client.credentials.accessToken} :: refresh token : ${client.credentials.refreshToken}');
    
    return client;
}

解决方案

1. 实现password授权类型的登录界面

登录界面为普通表单结构,包含用户名、密码输入框和登录按钮,点击按钮时调用登录方法,同时处理加载状态与错误提示:

class LoginScreen extends StatefulWidget {
  @override
  _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLoading = false;

  Future<void> _handleLogin() async {
    setState(() => _isLoading = true);
    try {
      final client = await authenticateUser(
        username: _usernameController.text,
        password: _passwordController.text,
        selCompanyUrl: "你的后端基础地址",
      );
      // 解析用户信息并保存
      final userInfo = parseJwtUserInfo(client.credentials.accessToken);
      await UserTokenManager.saveUser(userInfo['user_id'], client.credentials);
      await UserTokenManager.setCurrentUser(userInfo['user_id']);
      // 跳转首页
      Navigator.pushReplacementNamed(context, '/home');
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('登录失败:${e.toString()}')),
      );
    } finally {
      setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('登录')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _usernameController,
              decoration: InputDecoration(labelText: '用户名', border: OutlineInputBorder()),
              textInputAction: TextInputAction.next,
            ),
            SizedBox(height: 12),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: '密码', border: OutlineInputBorder()),
              obscureText: true,
              textInputAction: TextInputAction.done,
              onSubmitted: (_) => _handleLogin(),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _isLoading ? null : _handleLogin,
              child: _isLoading 
                  ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 
                  : Text('登录'),
              style: ElevatedButton.styleFrom(minimumSize: Size(double.infinity, 48)),
            ),
          ],
        ),
      ),
    );
  }
}

2. Access Token自动刷新机制

利用oauth2库的Client自带刷新能力,封装自动刷新逻辑,提前检测Token过期时间:

class AutoRefreshClient {
  static oauth2.Client? _client;

  static Future<oauth2.Client> getClient() async {
    if (_client == null) {
      // 从本地加载当前用户凭证
      final credentials = await UserTokenManager.getCurrentCredentials();
      if (credentials == null) throw Exception('未登录');
      _client = oauth2.Client(credentials, identifier: 'clientID', secret: 'ClientPassword');
    }

    // 提前5分钟刷新即将过期的Token
    final timeUntilExpire = _client!.credentials.expiration!.difference(DateTime.now()).inMinutes;
    if (_client!.credentials.isExpired || timeUntilExpire < 5) {
      try {
        _client = await _client!.refreshCredentials();
        // 保存刷新后的凭证
        await UserTokenManager.updateCurrentCredentials(_client!.credentials);
      } catch (e) {
        throw Exception('Token刷新失败,请重新登录');
      }
    }
    return _client!;
  }
}

3. 多用户登录及Token管理

使用shared_preferences存储多用户凭证,封装用户管理器实现多用户切换、列表展示:

import 'package:shared_preferences/shared_preferences.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import 'dart:convert';

class UserTokenManager {
  static const _kUsersKey = 'saved_users';
  static const _kCurrentUserIdKey = 'current_user_id';

  // 保存单个用户凭证
  static Future<void> saveUser(String userId, oauth2.Credentials credentials) async {
    final prefs = await SharedPreferences.getInstance();
    final usersJson = prefs.getString(_kUsersKey) ?? '{}';
    final usersMap = jsonDecode(usersJson) as Map<String, dynamic>;
    usersMap[userId] = credentials.toJson();
    await prefs.setString(_kUsersKey, jsonEncode(usersMap));
  }

  // 设置当前活跃用户
  static Future<void> setCurrentUser(String userId) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_kCurrentUserIdKey, userId);
  }

  // 获取当前用户凭证
  static Future<oauth2.Credentials?> getCurrentCredentials() async {
    final prefs = await SharedPreferences.getInstance();
    final currentUserId = prefs.getString(_kCurrentUserIdKey);
    if (currentUserId == null) return null;

    final usersJson = prefs.getString(_kUsersKey) ?? '{}';
    final usersMap = jsonDecode(usersJson) as Map<String, dynamic>;
    final credsJson = usersMap[currentUserId];
    return credsJson != null ? oauth2.Credentials.fromJson(credsJson) : null;
  }

  // 更新当前用户凭证
  static Future<void> updateCurrentCredentials(oauth2.Credentials credentials) async {
    final prefs = await SharedPreferences.getInstance();
    final currentUserId = prefs.getString(_kCurrentUserIdKey);
    if (currentUserId == null) return;

    final usersJson = prefs.getString(_kUsersKey) ?? '{}';
    final usersMap = jsonDecode(usersJson) as Map<String, dynamic>;
    usersMap[currentUserId] = credentials.toJson();
    await prefs.setString(_kUsersKey, jsonEncode(usersMap));
  }

  // 获取所有已登录用户列表(含基础信息)
  static Future<List<Map<String, dynamic>>> getAllUsers() async {
    final prefs = await SharedPreferences.getInstance();
    final usersJson = prefs.getString(_kUsersKey) ?? '{}';
    final usersMap = jsonDecode(usersJson) as Map<String, dynamic>;

    return usersMap.entries.map((entry) {
      final creds = oauth2.Credentials.fromJson(entry.value);
      final userInfo = parseJwtUserInfo(creds.accessToken);
      return {
        'user_id': entry.key,
        'email': userInfo['email'],
        'company_name': userInfo['company_name'],
      };
    }).toList();
  }

  // 切换用户
  static Future<void> switchUser(String userId) async {
    await setCurrentUser(userId);
    // 重置客户端实例,确保下次获取时加载新用户凭证
    AutoRefreshClient.resetClient();
  }
}

// 给AutoRefreshClient添加重置方法
extension ResetClient on AutoRefreshClient {
  static void resetClient() {
    _client = null;
  }
}

解析JWT获取用户信息

后端已将用户信息嵌入JWT,使用jwt_decoder库解析Token:

import 'package:jwt_decoder/jwt_decoder.dart';

Map<String, dynamic> parseJwtUserInfo(String accessToken) {
  try {
    final decoded = JwtDecoder.decode(accessToken);
    return {
      'user_id': decoded['user_id'],
      'email': decoded['email'],
      'user_mode': decoded['user_mode'],
      'company_id': decoded['company_id'],
      'company_name': decoded['company_name'],
      'authorities': decoded['authorities'],
    };
  } catch (e) {
    return {};
  }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 05:00:54