SpringCloud系列——SSO 單點登錄


  前言

  作為分布式項目,單點登錄是必不可少的,文本基於之前的的博客(猛戳: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、碼雲:

  GitHub:https://github.com/huanzi-qch/springCloud

  碼雲:https://gitee.com/huanzi-qch/springCloud


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM