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
相关产品推荐
相关产品推荐

