前几篇说的都是基于session的SSO(客户端应用的session、认证服务器的session),客户端应用拿到认证服务器返回的token后,将其存在自己的session, 用户登录状态是存在服务器端的。

本篇要说的是,要实现一个基于浏览器cookie的SSO,客户端应用获取到令牌后,不是将其存到session,而是写入浏览器cookie,这个改变会带来一些列问题,本篇将解决这些问题。

在OAuth授权回调里处理

客户端应用 客户token后的改造,在OAuth授权回调里处理,拿到token后写入cookie:

 

CookieTokenFilter 

在客户端应用,引入zuul的依赖,写一个CookieTokenFilter,从cookie拿出token 加在请求头里。

 

package com.nb.security.admin;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 从cookie获取token,统一加到请求头中去
 */
@Component
public class CookieTokenFilter extends ZuulFilter {

    private RestTemplate restTemplate = new RestTemplate();

    @Override
    public Object run() throws ZuulException {
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();
        HttpServletResponse response = requestContext.getResponse();

        String accessToken = getCookie("nb_access_token");
        if(StringUtils.isNotBlank(accessToken)){
            //令牌放到请求头
            requestContext.addZuulRequestHeader("Authorization","Bearer "+accessToken);
        }else {
            //从cookie把不到token说明token已过期,刷新令牌
            String refreshToken = getCookie("nb_refresh_token");
            if(StringUtils.isNotBlank(refreshToken)){
                String oauthServiceUrl = "http://gateway.nb.com:9070/token/oauth/token";
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);//不是json请求
                //网关的appId,appSecret,需要在数据库oauth_client_details注册
                headers.setBasicAuth("admin","123456");

                MultiValueMap<String,String> params = new LinkedMultiValueMap<>();
                params.add("refresh_token",refreshToken);//授权码
                params.add("grant_type","refresh_token");//授权类型-刷新令牌


                HttpEntity<MultiValueMap<String,String>> entity = new HttpEntity<>(params,headers);

                //刷新令牌的时候,可能refresh_token也过期了,这里进行处理,让用户重新走授权流程
                try{
                    ResponseEntity<AccessToken> newToken = restTemplate.exchange(oauthServiceUrl, HttpMethod.POST, entity, AccessToken.class);
                    //令牌放到请求头
                    requestContext.addZuulRequestHeader("Authorization","Bearer "+newToken.getBody().getAccess_token());
                    //基于 Cookie的SSO,拿到token后写入浏览器Cookie
                    Cookie accessTokenCookie = new Cookie("nb_access_token",newToken.getBody().getAccess_token());
                    accessTokenCookie.setMaxAge(newToken.getBody().getExpires_in().intValue()-3);//有效期
                    accessTokenCookie.setDomain("nb.com");//所有以nb.com结尾的二级域名都可以访问到cookie
                    accessTokenCookie.setPath("/");
                    response.addCookie(accessTokenCookie);

                    Cookie refreshTokenCookie = new Cookie("nb_refresh_token",newToken.getBody().getRefresh_token());
                    refreshTokenCookie.setMaxAge(2592000);//这里随便写一个很大的值(没用),如果是过期的token服务器将处理的。
                    refreshTokenCookie.setDomain("nb.com");//所有以nb.com结尾的二级域名都可以访问到cookie
                    refreshTokenCookie.setPath("/");
                    response.addCookie(refreshTokenCookie);
                }catch (Exception e){
                    //有异常,重新登录
                    requestContext.setSendZuulResponse(false);//zuul过滤器不往下走了
                    requestContext.setResponseStatusCode(500);//响应状态码
                    requestContext.setResponseBody("{\"message\":\"refresh fail\"}");
                    requestContext.getResponse().setContentType("application/json");
                }
            }else {
                //没用refresh——token,重新登录
                requestContext.setSendZuulResponse(false);//zuul过滤器不往下走了
                requestContext.setResponseStatusCode(500);//响应状态码
                requestContext.setResponseBody("{\"message\":\"refresh fail\"}");
                requestContext.getResponse().setContentType("application/json");
            }
        }

        return null;
    }

    private String getCookie(String name) {
        String result = null;
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletRequest request = requestContext.getRequest();
        Cookie[] cookies = request.getCookies();
        for(Cookie cookie : cookies){
            if(StringUtils.equals(cookie.getName(),name)){
                result = cookie.getValue();
                break;
            }
        }
        return result;
    }

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 1;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }
}

 

 

 

 

客户端判断用户登录状态

在前端服务器判断用户是否登录,之前基于session的SSO的处理是,会在客户端应用admin里的session里着token,往前端服务器发了一个/me请求,session如果有东西说明用户已登录,现在客户端应用session里已经不存token了,客户端应用没办法知道你是否已经等了,所以这里需要换一下,就换成,在客户端应用的页面,往网关发一个/api/user/me请求,因为yml里已经配置了,/api/开头的请求,都会转发到网关。

客户端页面的改造:

 

 前端服务器Controller在基于session-token方案时候判断用户登录状态,不用了:

 

 

 在网关上,由于从客户端应用admin过来的请求,会在请求头里带一个token,然后经过了网关的权限过滤器后,会从token解析出用户名,放在请求头传下去:

 

 

 

这里加一个MeFilter,排序Order在授权过滤器之后,专门映射处理/user/me请求,它不往任何一个服务转发,只是从请求头拿username,如果拿得到,就说明用户是登录状态。

 

 

 实验

1,启动4个服务

 

 2,访问客户端应用 admin

 3,点击去登录,跳转到认证服务器的登录页

 

 

 3,输入用户名aaa(随便输入,认证服务器没校验)密码123456 (认证服务器写死的),点击登录,可以看到,一级域名nb.com下的cookie里已经存入了access_token、refresh_token 。

点击【获取订单信息】按钮,调用订单服务,会携带cookie里的token,然后在客户端admin上, CookieTokenFilter 从cookie里读取到access_token和refresh_token,携带到请求头,转发给网关,网关校验token后,再将请求转发给订单服务。

 

 到现在已经实现了基于cookie 的SSO,token信息是存在cookie里的,客户端应用的session里没有存token信息。

模拟access_token失效后,客户端应用admin 拿refresh_token 去认证服务器换取access_token。

客户端应用配置表里,access_token失效时间是20秒,refresh_token 失效时间是30秒

 

 

访问订单服务 正常是70多毫秒,大概在17秒(cookie失效时间是20-3秒)后,可以看到访问订单服务时间是200多毫秒,此时在admin上是拿refresh_token去认证服务器刷新了acces_token。

 

30秒后,refresh_token也失效了,调用订单服务,会返回异常,捕获这个异常,前端做判断,给用户提示,让用户退出登录。

 

 

 logout

 function logout() {
        //1浏览器cookie失效掉
        $.removeCookie('nb_access_token',{domain:'nb.com',path:'/'});
        $.removeCookie('nb_refresh_token',{domain:'nb.com',path:'/'});
        //2,将认证服务器的session失效, /logout 是SpringSecurity OAuth默认的退出过滤器
        // org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
        window.location.href = "http://auth.nb.com:9090/logout?redirect_uri=http://admin.nb.com:8080/index";
    }

 

 这样就在refresh_token失效后,就完全退出登录,跳转客户端的登录页

 目前的架构是这样的,token信息都存在了浏览器cookie,客户端应用并没有存token信息

 

基于cookie的SSO的优缺点

1,登录状态  用户的登录状态存在了浏览器的cookie,当cookie里的refresh_token失效的时候才会去认证服务器做登录 。这种方案不需要在认证服务器上设置有效期很长的session,只要一个很短的就可以了,比如30分钟,因为决定能不能访问服务的不是认证服务器的session,而是浏览器cookie里的refresh_token

2,安全性低,token存在了浏览器,有一定的风险(使用https,缩短access_token有效期)
3,可控性低,refresh_token和access_token存在了客户的浏览器里,没办法主动失效掉。
4,跨域:cookie只能放在nb.com ,只有nb.com的二级域名(admin.nb.com 、order.nb.com等)可以做SSO

好处:
复杂程度低,相对于基于session的SSO来说,只需要做access_token和refresh_token过期的处理
不占服务器的资源,适合于海量用户。

 

 代码github : https://github.com/lhy1234/springcloud-security/tree/chapt-5-7-tokensso 如果对你帮助了,给个小星星呗

 

欢迎关注个人公众号一起交流学习: