前言
作為分布式項目,單點登錄是必不可少的,文本基於之前的的博客(猛戳:SpringCloud系列——Zuul 動態路由,SpringBoot系列——Redis)記錄Zuul配合Redis實現一個簡單的sso單點登錄實例
sso單點登錄思路:
1、訪問分布式系統的任意請求,被Zuul的Filter攔截過濾
2、在run方法里實現過濾規則:cookie有令牌accessToken且作為key存在於Redis,或者訪問的是登錄頁面、登錄請求則放行
3、否則,將重定向到sso-server的登錄頁面且原先的請求路徑作為一個參數;response.sendRedirect("http://localhost:10010/sso-server/sso/loginPage?url=" + url);
4、登錄成功,sso-server生成accessToken,並作為key(用戶名+時間戳,這里只是demo,正常項目的令牌應該要更為復雜)存到Redis,value值存用戶id作為value(或者直接存儲可暴露的部分用戶信息也行)設置過期時間(我這里設置3分鍾);設置cookie:new Cookie("accessToken",accessToken);,設置maxAge(60*3);、path("/");
5、sso-server單點登錄服務負責校驗用戶信息、獲取用戶信息、操作Redis緩存,提供接口,在eureka上注冊
代碼編寫
sso-server
首先我們創建一個單點登錄服務sso-server,並在eureka上注冊(創建項目請參考之前的SpringCloud系列博客跟 SpringBoot系列——Redis)

login.html
我們這里需要用到頁面,要先maven引入thymeleaf
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>登錄頁面</title> </head> <body> <form action="/sso-server/sso/login" method="post"> <input name="url" type="hidden" th:value="${url}"/> 用戶名:<input name="username" type="text"/> 密碼:<input name="password" type="password"/> <input value="登錄" type="submit"/> </form> </body> </html>
提供如下接口
@RestController @EnableEurekaClient @SpringBootApplication public class SsoServerApplication { public static void main(String[] args) { SpringApplication.run(SsoServerApplication.class, args); } @Autowired private StringRedisTemplate template; /** * 判斷key是否存在 */ @RequestMapping("/redis/hasKey/{key}") public Boolean hasKey(@PathVariable("key") String key) { try { return template.hasKey(key); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 校驗用戶名密碼,成功則返回通行令牌(這里寫死huanzi/123456) */ @RequestMapping("/sso/checkUsernameAndPassword") private String checkUsernameAndPassword(String username, String password) { //通行令牌 String flag = null; if ("huanzi".equals(username) && "123456".equals(password)) { //用戶名+時間戳(這里只是demo,正常項目的令牌應該要更為復雜) flag = username + System.currentTimeMillis(); //令牌作為key,存用戶id作為value(或者直接存儲可暴露的部分用戶信息也行)設置過期時間(我這里設置3分鍾) template.opsForValue().set(flag, "1", (long) (3 * 60), TimeUnit.SECONDS); } return flag; } /** * 跳轉登錄頁面 */ @RequestMapping("/sso/loginPage") private ModelAndView loginPage(String url) { ModelAndView modelAndView = new ModelAndView("login"); modelAndView.addObject("url", url); return modelAndView; } /** * 頁面登錄 */ @RequestMapping("/sso/login") private String login(HttpServletResponse response, String username, String password, String url) { String check = checkUsernameAndPassword(username, password); if (!StringUtils.isEmpty(check)) { try { Cookie cookie = new Cookie("accessToken", check); cookie.setMaxAge(60 * 3); //設置域 // cookie.setDomain("huanzi.cn"); //設置訪問路徑 cookie.setPath("/"); response.addCookie(cookie); //重定向到原先訪問的頁面 response.sendRedirect(url); } catch (IOException e) { e.printStackTrace(); } return null; } return "登錄失敗"; } }
zuul-server
引入feign,用於調用sso-server服務
<!-- feign --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>
創建SsoFeign.java接口
@FeignClient(name = "sso-server", path = "/") public interface SsoFeign { /** * 判斷key是否存在 */ @RequestMapping("redis/hasKey/{key}") public Boolean hasKey(@PathVariable("key") String key); }
啟動類加入@EnableFeignClients注解,否則啟動會報錯,無法注入SsoFeign對象
@EnableZuulProxy @EnableEurekaClient @EnableFeignClients @SpringBootApplication public class ZuulServerApplication { public static void main(String[] args) { SpringApplication.run(ZuulServerApplication.class, args); } @Bean public AccessFilter accessFilter() { return new AccessFilter(); } }
修改AccessFilter過濾邏輯,注入feign接口,用於調用sso-server檢查Redis,修改run方法的過濾邏輯
/** * Zuul過濾器,實現了路由檢查 */ public class AccessFilter extends ZuulFilter { @Autowired private SsoFeign ssoFeign; /** * 通過int值來定義過濾器的執行順序 */ @Override public int filterOrder() { // PreDecoration之前運行 return PRE_DECORATION_FILTER_ORDER - 1; } /** * 過濾器的類型,在zuul中定義了四種不同生命周期的過濾器類型: * public static final String ERROR_TYPE = "error"; * public static final String POST_TYPE = "post"; * public static final String PRE_TYPE = "pre"; * public static final String ROUTE_TYPE = "route"; */ @Override public String filterType() { return PRE_TYPE; } /** * 過濾器的具體邏輯 */ @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); HttpServletRequest request = ctx.getRequest(); HttpServletResponse response = ctx.getResponse(); //訪問路徑 String url = request.getRequestURL().toString(); //從cookie里面取值(Zuul丟失Cookie的解決方案:https://blog.csdn.net/lindan1984/article/details/79308396) String accessToken = request.getParameter("accessToken"); Cookie[] cookies = request.getCookies(); if(null != cookies){ for (Cookie cookie : cookies) { if ("accessToken".equals(cookie.getName())) { accessToken = cookie.getValue(); } } } //過濾規則:cookie有令牌且存在於Redis,或者訪問的是登錄頁面、登錄請求則放行 if (url.contains("sso-server/sso/loginPage") || url.contains("sso-server/sso/login") || (!StringUtils.isEmpty(accessToken) && ssoFeign.hasKey(accessToken))) { ctx.setSendZuulResponse(true); ctx.setResponseStatusCode(200); return null; } else { ctx.setSendZuulResponse(false); ctx.setResponseStatusCode(401); //重定向到登錄頁面 try { response.sendRedirect("http://localhost:10010/sso-server/sso/loginPage?url=" + url); } catch (IOException e) { e.printStackTrace(); } return null; } } /** * 返回一個boolean類型來判斷該過濾器是否要執行 */ @Override public boolean shouldFilter() { return true; } }
修改配置文件,映射sso-server代理路徑,超時時間與丟失cookie的解決
zuul.routes.sso-server.path=/sso-server/** zuul.routes.sso-server.service-id=sso-server zuul.host.socket-timeout-millis=60000 zuul.host.connect-timeout-millis=10000 #Zuul丟失Cookie的解決方案:https://blog.csdn.net/lindan1984/article/details/79308396 zuul.sensitive-headers=
測試效果
啟動eureka、zuul-server、sso-server、config-server、myspringboot、springdatajpa(由兩個應用組成,實現了ribbon負載均衡),記得啟動我們的RabbitMQ服務和Redis服務!

剛開始,沒有cookie且無Redis的情況下,瀏覽器訪問 http://localhost:10010/myspringboot/feign/ribbon,被zuul-server攔截重定向到sso-server登錄頁面


開始登錄校驗,為了方便演示,我將密碼的type改成text
登錄失敗,返回提示語

登錄成功,重定向到之前的請求

cookie的值,以及過期時間

3分鍾后我們再次訪問 http://localhost:10010/myspringboot/feign/ribbon,cookie、Redis失效,需要從新登錄



擴展
我們還缺了重要的一種情況,那就是靜態文件的處理,我們先把feign/ribbon接口改一下,並且新增ribbon.html文件
@RequestMapping("/ribbon")
public ModelAndView ribbon() {
return new ModelAndView("ribbon","text","springdatejpa -- 我的端口是:10086") ;
}
@RequestMapping("/ribbon")
public ModelAndView ribbon() {
return new ModelAndView("ribbon","text","springdatejpa -- 我的端口是:10088") ;
}
<!DOCTYPE html> <!--解決idea thymeleaf 表達式模板報紅波浪線--> <!--suppress ALL --> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Ribbon測試</title> </head> <body> <h3 th:text="${text}"></h3> </body> <!-- 引入靜態資源 --> <script th:src="@{/js/jquery-1.9.1.min.js}" type="application/javascript"></script> </html>
處理靜態資源
如果我們按照常規去引入項目的靜態資源文件,thymeleaf的@{取到的值是http://localhost:10010/,因此會報404 注:這兩個工程的靜態文件目錄如下:

<!-- 引入靜態資源 --> <script th:src="@{/js/jquery-1.9.1.min.js}" type="application/javascript"></script>

本來想通過Zuul去轉發請求,結果還是不行,上網一查發現有人說:zuul我們只用來做服務的轉發,不用做頁面的轉發。頁面中包含的靜態資源沒辦法直接通過zuul獲取對應的靜態資源。
<!-- 引入靜態資源 --> <script th:src="@{/myspringboot/js/jquery-1.9.1.min.js}" type="application/javascript"></script>

經過考慮,我這里采用讀取當前頁面文件所在的工程的靜態文件,就不經過Zuul了,先在當前工程里聲明好baseUrl,通過使用thymeleaf取國際化文件的方法,取到當前頁面文件所在工程的baseUrl路徑(需要先實現springboot國際化,具體配置請戳之前的博客:SpringBoot系列——i18n國際化),並且各自在自己工程的國際化文件新增:
baseUrl=http://localhost:10086
baseUrl=http://localhost:10088
ribbon.html做如下修改(兩個工程都一樣)
<!DOCTYPE html> <!--解決idea thymeleaf 表達式模板報紅波浪線--> <!--suppress ALL --> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Ribbon測試</title> </head> <body> <h3 th:text="${text}"></h3> <button onclick="getData()">獲取后台數據</button> <span id="spanTest"></span> </body> <!-- 引入靜態資源 --> <script th:src="#{baseUrl}+'/js/jquery-1.9.1.min.js'" type="application/javascript"></script> <script th:inline="javascript"> ctx = [[${#request.getContextPath()}]];//應用路徑,這里取到的是Zuul的路徑 function getData() { $.post(ctx + "/myspringboot/feign/getData",null,function (data) { $("#spanTest").text(data); }); } </script> </html>
引入成功

處理API接口
后台API接口是必須要走Zuul的,接上面的頁面,我們有一個簡單的測試按鈕,請求getDataAPI接口
我們先給實現了Ribbon負載均衡的springdatajpa(由兩個工程組成)新增連個測試接口
@PostMapping("/getData")
public String getData() {
return "springdatejpa -- 我的端口是:10086" ;
}
@PostMapping("/getData")
public String getData() {
return "springdatejpa -- 我的端口是:10088" ;
}
然后給myspringboot工程新增一個Feign接口、以及一個controller接口
@FeignClient(name = "springdatejpa", path = "/user/") public interface MyspringbootFeign { //此處省略之前的接口 @PostMapping("/getData") String getData(); }
/** * feign調用 */ @PostMapping("feign/getData") String getData(){ return myspringbootFeign.getData(); }
整體效果如下

如果accessToken失效了,這接口將無法訪問,需要刷新重新登錄

后記
sso單點登錄就記錄到這里,這里只是實現了單機版的sso,以后在進行升級吧。
問題報錯:我們在sso-server設置cookie后,在zuul-server的run方法里獲取不到設置的cookie,去瀏覽器查看,cookie沒有設置成功,Zuul丟失Cookie
解決方案:Zuul丟失Cookie的解決方案:https://blog.csdn.net/lindan1984/article/details/79308396
補充
2019-06-25補充:不知道大家發現沒有,我們之前在Zuul過濾器獲取訪問路徑用的是String url = request.getRequestURL().toString();,這樣獲取有一個問題,那就是如果url后面有參數(?username=aaa&password=123),這樣獲取就會丟失這些參數,先給大家演示一下
訪問:http://localhost:10010/sso-server/sso/redis/hasKey?username=aaa&pasword=123
跳轉登錄頁面,參數url已經丟失了原先的參數?username=aaa&password=123:http://localhost:10010/sso-server/sso/loginPage?url=http://localhost:10010/sso-server/sso/redis/hasKey

因此我們需要在重定向之前對get請求的參數進行處理,run方法獲取url后還需要設置參數,其他的請求則直接跳轉首頁或者固定頁面即可
/** * 過濾器的具體邏輯 */ @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); HttpServletRequest request = ctx.getRequest(); HttpServletResponse response = ctx.getResponse(); //訪問路徑 StringBuilder url = new StringBuilder(request.getRequestURL().toString()); //從cookie里面取值(Zuul丟失Cookie的解決方案:https://blog.csdn.net/lindan1984/article/details/79308396) String accessToken = request.getParameter("accessToken"); Cookie[] cookies = request.getCookies(); if (null != cookies) { for (Cookie cookie : cookies) { if ("accessToken".equals(cookie.getName())) { accessToken = cookie.getValue(); } } } //過濾規則: //訪問的是登錄頁面、登錄請求則放行 if (url.toString().contains("sso-server/sso/loginPage") || url.toString().contains("sso-server/sso/login") || //cookie有令牌且存在於Redis (!StringUtils.isEmpty(accessToken) && ssoFeign.hasKey(accessToken)) ) { ctx.setSendZuulResponse(true); ctx.setResponseStatusCode(200); return null; } else { ctx.setSendZuulResponse(false); ctx.setResponseStatusCode(401); //如果是get請求處理參數,其他請求統統跳轉到首頁 String method = request.getMethod(); if("GET".equals(method)){ url.append("?"); Map<String, String[]> parameterMap = request.getParameterMap(); Object[] keys = parameterMap.keySet().toArray(); for (int i = 0; i < keys.length; i++) { String key = (String) keys[i]; String value = parameterMap.get(key)[0]; url.append(key).append("=").append(value).append("&"); } //處理末尾的&符合 url.delete(url.length() -1,url.length()); }else{ //首頁鏈接,或者其他固定頁面 url = new StringBuilder("XXX"); } //重定向到登錄頁面 try { response.sendRedirect("http://localhost:10010/sso-server/sso/loginPage?url=" + url); } catch (IOException e) { e.printStackTrace(); } return null; } }
給大家看一下改動后的效果

如果是其他請求

PS:其實這樣響應處理一點都不友好,應該做如下約定:后端響應特定狀態碼(例如:301)時,同時會響應對應的url鏈接(例如系統首頁鏈接),前端發起post、delete請求等需要進行判斷,然后在js進行頁面跳轉,這樣的話用戶的體驗會更好,系統更加健全
代碼開源
代碼已經開源、托管到我的GitHub、碼雲:
