SpringBoot整合Security實現權限控制框架(案例詳解)
我想每個寫項目的人,都肯定會遇到控制權限這個問題.
例如這個這個鏈接隻能管理員訪問,那個鏈接丫隻能超級管理員訪問等等,實現方式也有多種多樣,控制的粒度也不一樣
。
以前剛學的時候,不會框架,大都是手寫註解+過濾器
來進行權限的控制,但這樣增加瞭過濾器的負擔。用起來也會稍微有些麻煩,粒度不太好控制。
用框架的話,就是封裝瞭更多的操作,讓一切更簡單吧。當然不局限於Security,還有像Shiro安全框架,這兩種非常常見。
一起加油吧!!!
先看個圖舒緩一下,準備開始吧
下面就開始吧!!!👇
一、前言
介紹:
Spring Security是一個能夠為基於Spring的企業應用系統提供聲明式的安全訪問控制解決方案的安全框架。它提供瞭一組可以在Spring應用上下文中配置的Bean,充分利用瞭Spring IoC,DI(控制反轉Inversion of Control ,DI:Dependency Injection 依賴註入)和AOP(面向切面編程)功能,為應用系統提供聲明式的安全訪問控制功能,減少瞭為企業系統安全控制編寫大量重復代碼的工作。
官網:
SpringSecurity 最新
SpringSecurity 5.0.6版本
優缺點:
優點
- Spring Boot 官方提供瞭大量的非常方便的開箱即用的 Starter ,包括 Spring Security 的 Starter ,使得在 Spring Boot 中使用 Spring Security 變得更加容易。
- Spring Security功能強大,比較好用。
缺點
- Spring Security 是一個重量級的安全管理框架,
- Spring Security概念復雜,配置繁瑣(這個確實,沒法逃開)
案例
我們在訪問一個網站時,大都都會設置普通用戶能有的權限,然後管理員有的權限,再就是超級管理員等等,這次就是實現這樣一個案例。
項目結構:
二、環境準備
2.1、數據庫表
CREATE TABLE `account` ( `id` int(10) NOT NULL AUTO_INCREMENT, `username` varchar(25) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, `role` varchar(25) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic; INSERT INTO `account` VALUES (1, 'user', '$2a$10$1MHNdZS.oCICxLRVbnNBZe4CRn9Rk1MVQhasSMhHr0G4BCNQjPpna', 'ROLE_USER'); INSERT INTO `account` VALUES (2, 'admin', '$2a$10$dKkrkgVzaCPX74TvxOjwNuFJjIRJeAuDPKFntwNwRvRHkwIAHV5Q6', 'ROLE_ADMIN'); INSERT INTO `account` VALUES (3, 'super_admin', '$2a$10$CqOXnSp6oks9UTvsops4U.0vMGbUE2Bp28xKaPmlug4W8Mk59Sj8y', 'ROLE_SUPER_ADMIN'); INSERT INTO `account` VALUES (4, 'test', '$2a$10$SQsuH1XfxHdsVmf2nE75wOAE6GHm1nd/xDp/08KYJmtbzJt2J6xIG', 'TEST');
2.2、導入依賴
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.2</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.1</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!--java版本太高 向下兼容的包--> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.0</version> </dependency> </dependencies>
2.3、配置文件
# 應用名稱 spring.application.name=demo # 應用服務 WEB 訪問端口 server.port=8080 spring.datasource.name=defaultDataSource # 數據庫連接地址 spring.datasource.url=jdbc:mysql://localhost:3306/security?serverTimezone=UTC # 數據庫用戶名&密碼: spring.datasource.username=root spring.datasource.password=123456 mybatis-plus.mapper-locations=classpath:mapper/**/*.xml logging.level.com.crush.security.mapper=DEBUG # token 存活時間 token.expire=3600000 token.key=123456
2.4、WebSecurityConfig Security的主要配置類:
import com.crush.security.auth.filter.JwtAuthenticationFilter; import com.crush.security.auth.filter.JwtAuthorizationFilter; import com.crush.security.auth.handle.MacLoginUrlAuthenticationEntryPoint; import com.crush.security.auth.handle.MyAccessDeniedHandler; import com.crush.security.auth.handle.MyLogoutSuccessHandler; import com.crush.security.auth.service.UserDetailServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; /** * @author crush */ @Configuration @EnableWebSecurity //啟用全局配置 @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { /**放行的路徑*/ private final String[] PATH_RELEASE = { "/login", "/all" }; /***根據用戶名找到用戶*/ @Autowired private UserDetailServiceImpl userDetailService; @Autowired private MacLoginUrlAuthenticationEntryPoint macLoginUrlAuthenticationEntryPoint; @Autowired private MyAccessDeniedHandler myAccessDeniedHandler; @Autowired private MyLogoutSuccessHandler myLogoutSuccessHandler; @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and().csrf().disable(); http.authorizeRequests() /**antMatchers (這裡的路徑) permitAll 這裡是允許所有人 訪問*/ .antMatchers(PATH_RELEASE).permitAll() /** 映射任何請求 */ .anyRequest() /** 指定任何經過身份驗證的用戶都允許使用URL。*/ .authenticated() /** 指定支持基於表單的身份驗證 */ .and().formLogin().permitAll() /** 允許配置異常處理。可以自己傳值進去 使用WebSecurityConfigurerAdapter時,將自動應用此WebSecurityConfigurerAdapter 。*/ .and().exceptionHandling() /** 設置要使用的AuthenticationEntryPoint。 macLoginUrlAuthenticationEntryPoint 驗證是否登錄*/ .authenticationEntryPoint(macLoginUrlAuthenticationEntryPoint) /** 指定要使用的AccessDeniedHandler 處理拒絕訪問失敗。*/ .accessDeniedHandler(myAccessDeniedHandler) /** 提供註銷支持。 使用WebSecurityConfigurerAdapter時,將自動應用此WebSecurityConfigurerAdapter 。 * 默認設置是訪問URL “ / logout”將使HTTP會話無效,清理配置的所有rememberMe()身份驗證,清除SecurityContextHolder , * 然後重定向到“ / login?success”,從而註銷用戶*/ .and().logout().logoutSuccessHandler(myLogoutSuccessHandler) /** 處理身份驗證表單提交。 授予權限 */ .and().addFilter(new JwtAuthenticationFilter(authenticationManager())) /** 處理HTTP請求的BASIC授權標頭,然後將結果放入SecurityContextHolder 。 */ .addFilter(new JwtAuthorizationFilter(authenticationManager())) /**不需要session */ .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } @Override public void configure(WebSecurity web) throws Exception { super.configure(web); } /** * * 因為使用瞭BCryptPasswordEncoder來進行密碼的加密,所以身份驗證的時候也的用他來判斷哈、, * @param auth * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder()); } /** * 密碼加密*/ @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
2.5、Security身份驗證
import com.crush.security.entity.MyUser; import com.crush.security.utils.JwtTokenUtils; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Collection; /** * 處理身份驗證表單提交。 * * @author crush */ public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private AuthenticationManager authenticationManager; public JwtAuthenticationFilter(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } /** * 執行實際的身份驗證。 * 該實現應執行以下操作之一: * 返回已驗證用戶的已填充驗證令牌,指示驗證成功 * 返回null,表示身份驗證過程仍在進行中。 在返回之前,實現應執行完成該過程所需的任何其他工作。 * 如果身份驗證過程失敗,則拋出AuthenticationException */ @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //輸入流中獲取到登錄的信息 try { MyUser loginUser = new ObjectMapper().readValue(request.getInputStream(), MyUser.class); logger.info("loginUser===>" + loginUser); /** * authenticate * 嘗試對傳遞的Authentication對象進行身份Authentication , * 如果成功,則返回完全填充的Authentication對象(包括授予的權限) * */ return authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginUser.getUsername(), loginUser.getPassword(), new ArrayList<>()) ); } catch (IOException e) { e.printStackTrace(); return null; } } /** * 成功驗證後調用的方法 * 如果驗證成功,就生成token並返回 */ @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { // 查看源代碼會發現調用getPrincipal()方法會返回一個實現瞭`UserDetails`接口的對象 // 所以就是JwtUser啦 MyUser user = (MyUser) authResult.getPrincipal(); String role = ""; // 因為在JwtUser中存瞭權限信息,可以直接獲取,由於隻有一個角色就這麼幹瞭 Collection<? extends GrantedAuthority> authorities = user.getAuthorities(); for (GrantedAuthority authority : authorities) { role = authority.getAuthority(); } // 根據用戶名,角色創建token並返回json信息 String token = JwtTokenUtils.createToken(user.getUsername(), role, false); user.setPassword(null); user.setToken(JwtTokenUtils.TOKEN_PREFIX + token); response.setStatus(HttpServletResponse.SC_OK); response.setHeader("token", JwtTokenUtils.TOKEN_PREFIX + token); response.setContentType("application/json;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(new ObjectMapper().writeValueAsString(user)); } /** * 驗證失敗時候調用的方法 */ @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(new ObjectMapper().writeValueAsString( "登錄失敗,賬號或密碼錯誤")); } }
2.6、Security授權
import com.crush.security.utils.JwtTokenUtils; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Collections; /** * 處理HTTP請求的BASIC授權標頭,然後將結果放入SecurityContextHolder 。 */ public class JwtAuthorizationFilter extends BasicAuthenticationFilter { public JwtAuthorizationFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String tokenHeader = request.getHeader(JwtTokenUtils.TOKEN_HEADER); // 如果請求頭中沒有Authorization信息則直接放行瞭 if (tokenHeader == null || !tokenHeader.startsWith(JwtTokenUtils.TOKEN_PREFIX)) { chain.doFilter(request, response); return; } // 如果請求頭中有token,則進行解析,並且設置認證信息 SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader)); super.doFilterInternal(request, response, chain); } /** * 這裡從token中獲取用戶信息並新建一個token*/ private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) { String token = tokenHeader.replace(JwtTokenUtils.TOKEN_PREFIX, ""); String username = JwtTokenUtils.getUsername(token.trim()); String role = JwtTokenUtils.getUserRole(token); if (username != null) { return new UsernamePasswordAuthenticationToken(username, null, Collections.singleton(new SimpleGrantedAuthority(role)) ); } return null; } }
2.7、UserDetailsService
UserDetailServiceImpl 實現瞭UserDetailsService
,用來加載用戶特定數據的核心接口。
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.crush.security.entity.MyUser; import com.crush.security.service.IMyUserService; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Slf4j @Service public class UserDetailServiceImpl implements UserDetailsService { final IMyUserService userService; public UserDetailServiceImpl(IMyUserService userService) { this.userService = userService; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { MyUser user = userService.getOne(new QueryWrapper<MyUser>().eq("username", username)); return user; } }
2.7、MacLoginUrlAuthenticationEntryPoint
/** * * 身份驗證沒有通過回調 */ @Component public class MacLoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter writer = httpServletResponse.getWriter(); writer.write(new ObjectMapper().writeValueAsString("未登錄!")); } }
2.8、MyAccessDeniedHandler
/** * 權限不足回調 */ @Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { httpServletResponse.setContentType("application/json;charset=utf-8"); httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); PrintWriter writer = httpServletResponse.getWriter(); writer.write(new ObjectMapper().writeValueAsString("不好意思,你的權限不足!")); } }
2.9、MyLogoutSuccessHandler
/** * 退出回調 */ @Component public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { httpServletResponse.setStatus(HttpServletResponse.SC_OK); httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter writer = httpServletResponse.getWriter(); writer.write(new ObjectMapper().writeValueAsString( "退出成功")); } }
2.10、JWT的工具類
生成token
package com.crush.security.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.util.Date; import java.util.HashMap; public class JwtTokenUtils { public static final String TOKEN_HEADER = "Authorization"; public static final String TOKEN_PREFIX = "Bearer "; private static final String SECRET = "jwtsecretdemo"; private static final String ISS = "echisan"; /** * 過期時間是3600秒,既是1個小時 */ private static final long EXPIRATION = 3600L; /** * 選擇瞭記住我之後的過期時間為7天 */ private static final long EXPIRATION_REMEMBER = 604800L; // 添加角色的key private static final String ROLE_CLAIMS = "rol"; /** * 修改一下創建token的方法 * * @param username * @param role * @param isRememberMe * @return */ public static String createToken(String username, String role, boolean isRememberMe) { String token = null; try { long expiration = isRememberMe ? EXPIRATION_REMEMBER : EXPIRATION; HashMap<String, Object> map = new HashMap<>(); map.put(ROLE_CLAIMS, role); token = Jwts.builder() .signWith(SignatureAlgorithm.HS512, SECRET) // 這裡要早set一點,放到後面會覆蓋別的字段 .setClaims(map) .setIssuer(ISS) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) .compact(); } catch (ExpiredJwtException e) { e.getClaims(); } return token; } /** * 從token中獲取用戶名 * * @param token * @return */ public static String getUsername(String token) { return getTokenBody(token).getSubject(); } /** * 從token中獲取roles * * @param token * @return */ public static String getUserRole(String token) { return (String) getTokenBody(token).get(ROLE_CLAIMS); } /** * 是否已過期 * * @param token * @return */ public static boolean isExpiration(String token) { return getTokenBody(token).getExpiration().before(new Date()); } private static Claims getTokenBody(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } public static void main(String[] args) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String user = encoder.encode("test"); System.out.println(user); } }
弄完上面這些,相關配置就都搞定瞭,剩下就是最簡單的編碼啦。
三、代碼 entity
@Data @EqualsAndHashCode(callSuper = false) @TableName("account") public class MyUser implements Serializable, UserDetails { private static final long serialVersionUID = 1L; private int id; private String username; private String password; // 1:啟用 , 0:禁用 @TableField(exist = false) private Integer enabled = 1; // 1:鎖住 , 0:未鎖 @TableField(exist = false) private Integer locked = 0; private String role; @TableField(exist = false) private String token; //授權 @Override public Collection<? extends GrantedAuthority> getAuthorities() { List<SimpleGrantedAuthority> authorities = new ArrayList<>(); SimpleGrantedAuthority authority = new SimpleGrantedAuthority(role); authorities.add(authority); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return locked == 0; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled == 1; } }
mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.crush.security.entity.MyUser; import org.springframework.stereotype.Repository; @Repository public interface MyUserMapper extends BaseMapper<MyUser> {}
service、impl
import com.baomidou.mybatisplus.extension.service.IService; import com.crush.security.entity.MyUser; public interface IMyUserService extends IService<MyUser> { }
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.crush.security.entity.MyUser; import com.crush.security.mapper.MyUserMapper; import com.crush.security.service.IMyUserService; import org.springframework.stereotype.Service; @Service public class MyUserServiceImpl extends ServiceImpl<MyUserMapper, MyUser> implements IMyUserService { }
controller
package com.crush.security.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class UserController { @RequestMapping("/all") String all() { return "在WebSecurityConfig中配置瞭放行,任何人都可以進行訪問"; } @PreAuthorize("permitAll()") @RequestMapping("/test") String test() { return "所有登錄的人都可以訪問"; } @PreAuthorize("hasRole('USER')") @RequestMapping("/user/userList") String userList() { return "role: user"; } @PreAuthorize("hasRole('ADMIN')") @RequestMapping("/admin/updateUser") String updateUser() { return "role: admin"; } @PreAuthorize("hasRole('SUPER_ADMIN')") @RequestMapping("/admin/superAdmin") String superAdmin() { return "role: superAdmin"; } @PreAuthorize("hasAnyRole('ADMIN','USER')") @RequestMapping("/userAndAdmin") String userAndAdminTest() { return "role: admin and user"; } @PreAuthorize("hasAnyRole('ADMIN')or hasAnyRole('SUPER_ADMIN')") @RequestMapping("/AdminAndSuperAdminTest") String AdminAndSuperAdminTest() { return "role: admin and super_admin"; } // hasAnyAuthority() 也是可以多個字符串 權限驗證,可以不跟ROLE_前綴 @PreAuthorize("hasAuthority('TEST') ") @RequestMapping("/ceshi2") String ceshi2() { return "hasAuthority:權限驗證,不過查的也是role那個字段,不過不用拼接上ROLE而已"; } }
四、測試
註
:我使用的測試工具是Postman
,另外login接口接收的數據是需要JSON類型的。
1)登錄
註意這裡的token,我們是需要把他記住,下次去請求要攜帶上。
2)測試管理員
3)測試hasAnyAuthority ()註解
hasAnyAuthority() 也是可以多個字符串 權限驗證,可以不跟ROLE_前綴
五、總結
Security框架和SpringBoot集成,其實上手特別快,但是如果要想研究的比較深刻的話,我覺得是比較困難的,上文講過,security是屬於一個重量級的框架,裡面很多東西特別多。使用方面肯定是沒有任何問題的。
到此這篇關於SpringBoot整合Security安全框架、控制權限的文章就介紹到這瞭,更多相關SpringBoot整合Security權限控制框架內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- SpringBoot整合Security安全框架實現控制權限
- springboot+jwt+springSecurity微信小程序授權登錄問題
- springboot2.5.2與 flowable6.6.0整合流程引擎應用分析
- Spring Data JPA的Audit功能審計數據庫的變更
- SpringSecurity OAtu2+JWT實現微服務版本的單點登錄的示例