最简单易懂的OSS单点登录设计与实现

前置知识

一、早期单一服务器,用户认证

在这里插入图片描述

当浏览器向服务器发送登录请求时,验证通过之后,会将用户信息存入DB或者文件中

缺点:单点性能压力,无法扩展

二、WEB应用集群,session共享模式

在这里插入图片描述

引入集群的概念,单应用可能重新部署在3台tomcat以上服务器,使用nginx来实现反向代理,解决了单点性能瓶颈。

缺点:

1、 多业务分布式数据独立管理,不适合统一维护一份session数据。

2、 分布式按业务功能切分,用户、认证解耦出来单独统一管理。

3、 cookie中使用sessionId 容易被篡改、盗取。

4、 跨顶级域名无法访问。

三、分布式,SSO(single sign on)模式

在这里插入图片描述

解决了用户信息独立,更好的分布式管理,可以自己扩展安全策略,解决了跨域问题。

缺点:

认证服务器访问压力较大。

几个基本概念

一、什么是跨域 Web SSO

域名通过“.”号切分后,从右往左看,不包含“.”的是顶级域名,包含一个“.”的是一级域名,包含两个“.”的是二级域名,以此类推。

例如对网址 http://www.cnblogs.com/baibaomen,域名部分是 www.cnblogs.com。用“.”拆分后从右往左看:

cookie.setDomain(“.cnblogs.com”);//最多设置到本域的一级域名这里

cookie.setDomain(“.baidu.com”);//最多设置到本域的一级域名这里

”com”不包含“.”,是顶级域名; “cnblogs.com”包含一个“.”,是一级域名;www.cnblogs.com 包含两个“.”,是二级域名。

跨域 Web SSO 指的是针对 Web 站点,各级域名不同都能处理的单点登录方案。

二、 cookie 的安全性限制:一级或顶级域名不同的网站无法读到彼此写的cookie。

所以 baidu.com 无法读到 cnblogs.com 写的 cookie。一级域名相同,只是二级或更高级域名不同的站点,可以通过设置 domain 参数共享 cookie读写。这种场景可以选择不跨域的 SSO 方案。

域名相同,只是 https 和 http 协议不同的 URL,默认 cookie 可以共享。知道这一点对处理 SSO 服务中心要登出

三、 协议是无状态协议。浏览器访问服务器时,要让服务器知道你是谁,只有两种方式:

方式一:把“你是谁”写入 cookie。它会随每次 HTTP 请求带到服务端;

方式二:在 URL、表单数据中带上你的用户信息(也可能在 HTTP 头部)。这种方式依赖于从特定的网页入口进入,因为只有走特定的入口,才有机会拼装出相应的信息,提交到服务端。

大部分 SSO 需求都希望不依赖特定的网页入口(集成门户除外),所以后一种方式有局限性。适应性强的方式是第一种,即在浏览器通过 cookie 保存用户信息相关凭据,随每次请求传递到服务端。我们采用的方案是第一种。

四、JWT实现无状态登录

JWT,全称是 Json Web Token, 是 JSON 风格轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权。

因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了 Rest 的无状态规范。

搭建OSS认证中心

一、完整业务流程

在这里插入图片描述

二、代码实现

本次示例使用SpringBoot工程搭建

1.新建OSS认证中心模块

在这里插入图片描述

核心依赖

<parent>
    <groupId>com.atguigu.gmall</groupId>
    <artifactId>gmall-parent</artifactId>
    <version>1.0-SNAPSHOT</version>
</parent>
<dependencies>
    <dependency>
        <groupId>com.atguigu.gmall</groupId>
        <artifactId>gmall-interface</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>

    <dependency>
        <groupId>com.atguigu.gmall</groupId>
        <artifactId>gmall-web-util</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

application.properties

server.port=8086
spring.thymeleaf.cache=false
spring.thymeleaf.mode=LEGACYHTML5
spring.dubbo.application.name=passport-web
spring.dubbo.registry.protocol=zookeeper
spring.dubbo.registry.address=192.168.67.163:2181
spring.dubbo.base-package=com.atguigu.gmall
spring.dubbo.protocol.name=dubbo
spring.dubbo.consumer.timeout=100000
spring.dubbo.consumer.check=false
2.登录页面html代码
<div class="si_bom1 tab" style="display: block;">
    <div  class="error">
        请输入账户名和密码
    </div>
    <form id="loginForm" action="/login" method="post">
        <ul>
            <li class="top_1">
                <img src="/img/user_03.png" class="err_img1"/>
                <input type="text" name="loginName" placeholder=" 邮箱/用户名/已验证手机"  class="user"/>
            </li>
            <li>
                <img src="/img/user_06.png" class="err_img2"/>
                <input type="password" name="passwd" placeholder=" 密码" class="password"/>
            </li>
            <li class="bri">
                <a href="">忘记密码</a>
            </li>
            <li class="ent"><button id="btn2" class="btn2"><a class="a">登&nbsp; &nbsp;录</a></button></li>
        </ul>
        <input type="hidden" id="originUrl" name="originUrl" th:value="${originUrl}"/>
    </form>
</div>
3.登录功能(生成token)

在这里插入图片描述

认证中心只负责认证和token的颁发

  • 用接受的用户名密码核对后台数据库

  • 将用户信息加载到写入redis,redis中有该用户视为登录状态。

  • 用userId+当前用户登录ip地址+密钥生成token

  • 重定向用户到之前的来源地址,同时把token作为参数附上。

1)核对后台登录信息+用户登录信息载入缓存
public UserInfo login(UserInfo userInfo){
    String passwd = DigestUtils.md5Hex(userInfo.getPasswd());
    userInfo.setPasswd(passwd);

    UserInfo userInfoResult = userInfoMapper.selectOne(userInfo);
    if(userInfoResult!=null){

        String userInfoKey="user:"+userInfoResult.getId()+"info";

        Jedis jedis = redisUtil.getJedis();
        String userInfoJson = JSON.toJSONString(userInfoResult);
        jedis.setex(userInfoKey,UserConst.sessionExpire,userInfoJson);

        return userInfoResult ;
    }else{
        return null;
    }
}
2)使用JWT工具类生成tooken
public class JwtUtil {

    public static String encode(String key,Map<String,Object> param,String salt){
        if(salt!=null){
            key+=salt;
        }
        JwtBuilder jwtBuilder = Jwts.builder().signWith(SignatureAlgorithm.HS256,key);

        jwtBuilder = jwtBuilder.setClaims(param);

        String token = jwtBuilder.compact();
        return token;

    }


    public  static Map<String,Object>  decode(String token ,String key,String salt){
        Claims claims=null;
        if (salt!=null){
            key+=salt;
        }
        try {
            claims= Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
        } catch ( JwtException e) {
            return null;
        }
        return  claims;
    }
}
3)Controller示例
@RequestMapping(value ="login",method = RequestMethod.POST)
@ResponseBody
public String  login(UserInfo userInfo,HttpServletRequest httpServletRequest){
    String remoteAddr = httpServletRequest.getHeader("x-forwarded-for");
    String userId = userManageService.login(userInfo);
    if(userId==null){
        return "fail";
    }else{
        Map map=new HashMap();
        map.put("userId",userId);
        map.put("nickName",userInfoResult.getNickName());

        String token = JwtUtil.encode(signKey, map, remoteAddr);
        return token;
    }

}
4 验证登录(token)功能,用jwt解析

在这里插入图片描述

当业务模块某个页面要检查当前用户是否登录时,提交到认证中心,认证中心进行检查校验,返回登录状态、用户Id和用户名称。

  • 利用密钥和IP检验token是否正确,并获得里面的userId

  • 用userId检查Redis中是否有用户信息,如果有延长它的过期时间。

  • 登录成功状态返回。

UserController示例

@RequestMapping(value ="verify",method = RequestMethod.POST)
@ResponseBody
public String verify(HttpServletRequest httpServletRequest){
    String token = httpServletRequest.getParameter("token");
    String curIp=httpServletRequest.getParameter("currentIp");
    Map<String, Object> map = JwtUtil.decode(token, signKey, curIp);
    JSONObject jsonObject=new JSONObject();
    String userId =(String) map.get("userId");
    boolean verify = userManageService.verify(userId);

    return  Boolean.toString(verify) ;

}

UserManageServiceImpl示例

public boolean verify(String userId){
    Jedis jedis = redisUtil.getJedis();
    String userInfoKey="user:"+userId+"info";
    Boolean exists = jedis.exists(userInfoKey);
    if(exists) {
        jedis.expire(userInfoKey, UserConst.sessionExpire);
    }
    return exists;
}
5.业务模块的登录检查(@注解与拦截器)

在这里插入图片描述

1)登录拦截器

继承成springmvc的HandlerInterceptorAdapter,通过重新它的preHandle方法实现,业务代码前的校验工作,所有web模块都需要的。

@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter{
    @Autowired
    AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(authInterceptor).addPathPatterns("/**");
        super.addInterceptors(registry);
    }
}
2)登录成功后跳转回来的处理(登录成功后写入cookie)
@Component
public class AuthInterceptor extends HandlerInterceptorAdapter
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String newToken = request.getParameter("newToken");
            if(newToken!=null&&newToken.length()>0){
                CookieUtil.setCookie(request,response,"token",newToken,WebConst.cookieExpire,false);
            }
            
            return true;
        }
}

其中用到了CookieUtil的工具,主要三个方法:从cookie中获得值,把值存入cookie, 设定cookie的作用域。代码如下:

public class CookieUtil {
    public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null || cookieName == null){
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookies.length; i++) {
                if (cookies[i].getName().equals(cookieName)) {
                    if (isDecoder) {//如果涉及中文
                        retValue = URLDecoder.decode(cookies[i].getValue(), "UTF-8");
                    } else {
                        retValue = cookies[i].getValue();
                    }
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }


    public static   void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else if (isEncode) {
                cookieValue = URLEncoder.encode(cookieValue, "utf-8");
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage >= 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request)// 设置域名的cookie
                cookie.setDomain(getDomainName(request));
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }



    /**
     * 得到cookie的域名
     */
    private static final String getDomainName(HttpServletRequest request) {
        String domainName = null;

        String serverName = request.getRequestURL().toString();
        if (serverName == null || serverName.equals("")) {
            domainName = "";
        } else {
            serverName = serverName.toLowerCase();
            serverName = serverName.substring(7);
            final int end = serverName.indexOf("/");
            serverName = serverName.substring(0, end);
            final String[] domains = serverName.split("\\.");
            int len = domains.length;
            if (len > 3) {
                // www.xxx.com.cn
                domainName = domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
            } else if (len <= 3 && len > 1) {
                // xxx.com or xxx.cn
                domainName = domains[len - 2] + "." + domains[len - 1];
            } else {
                domainName = serverName;
            }
        }

        if (domainName != null && domainName.indexOf(":") > 0) {
            String[] ary = domainName.split("\\:");
            domainName = ary[0];
        }
        System.out.println("domainName = " + domainName);
        return domainName;
    }
}
3)检查cookie中是否有token
  • 检查cookie中是否有token,如果有把cookie中的昵称取放入页面request属性中。

  • 检查是否需要验证登录。如果需要,调用认证模块接口。

    如果认证通过程序照常执行

    如果认证不通过,跳转到登录页面

  • 检查是否是登陆页面跳转回来的,如果附带新的token则把token保存到cookie中。

    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {


        String newToken = request.getParameter("newToken");
        if(newToken!=null&&newToken.length()>0){
            CookieUtil.setCookie(request,response,"token",newToken,WebConst.cookieExpire,false);
        }

//1 进行如果能从cookie把token取出来,进行解析,显示页面上。
        String token = CookieUtil.getCookieValue(request, "token", false);
        String userId=null;
        if(token!=null){
            Base64UrlCodec base64UrlCodec=new Base64UrlCodec();
            //  两个“.”之间的部分是实际内容
            String  tokenForDecode= StringUtils.substringBetween(token, ".");

            byte[] tokenByte = base64UrlCodec.decode(tokenForDecode);
            String tokenJson=new String(tokenByte,"UTF-8");
            System.out.println("tokenJson = " + tokenJson);

            JSONObject jsonObject = JSON.parseObject( tokenJson);

            userId = jsonObject.getString("userId");
            String nickName = jsonObject.getString("nickName");

            request.setAttribute("nickName",nickName);
        }



        return true;
    }
4)通过注@ LoginRequie解来检验方法是否需要验证

为了方便程序员在controller方法上标记,可以借助自定义注解的方式。比如某个controller方法需要验证用户登录,在方法上加入自定义的@LoginRequie。

自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequire {

    boolean autoRedirect() default true;
}

在拦截方法preHandle方法中继续添加,检验登录的代码

//检查是否需要验证用户已经登录
HandlerMethod handlerMethod =(HandlerMethod) handler;
LoginRequire methodAnnotation = handlerMethod.getMethodAnnotation(LoginRequire.class);
if(methodAnnotation!=null){
    String currentIp = request.getHeader("x-forwarded-for");

    Map map=new HashMap();
    map.put("currentIp",currentIp);
    map.put("token",token);
    String result=null;
    if(token !=null) {
        result = HttpclientUtil.doPost(WebConst.VERIFY_URL, map);
    }
    if(result!=null&&result.equals("true")){
        request.setAttribute("userId",userId); //只有验证过才能取到userId
        return true;
    }else{
        if(methodAnnotation.autoRedirect()) {
            String url = URLEncoder.encode(request.getRequestURL().toString(), "utf-8");

            response.sendRedirect(WebConst.LOGIN_URL + "?originUrl=" + url);
            return false;
        }
    }
}

以上方法,检查业务方法是否需要用户登录,如果需要就把cookie中的token和当前登录人的ip地址发给远程服务器进行登录验证,返回的result是验证结果true或者false。如果验证未登录,直接重定向到登录页面。

以上使用到了一个自定义的HttpclientUtil工具类,专门负责通过restful风格调用接口。

public class HttpclientUtil {

    public static String doGet(String url)   {
        // 创建Httpclient对象
        CloseableHttpClient httpclient = HttpClients.createDefault();
        // 创建http GET请求
        HttpGet httpGet = new HttpGet(url);
        CloseableHttpResponse response = null;
        try {
            // 执行请求
            response = httpclient.execute(httpGet);
            // 判断返回状态是否为200
            if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                HttpEntity entity = response.getEntity();
                String result = EntityUtils.toString(entity, "UTF-8");
                EntityUtils.consume(entity);
                httpclient.close();
                return result;
            }
            httpclient.close();
        }catch (IOException e){
            e.printStackTrace();
            return null;
        }
        return  null;
    }



    public static String doPost(String url, Map<String,String> paramMap)   {
        // 创建Httpclient对象
        CloseableHttpClient httpclient = HttpClients.createDefault();
        // 创建http Post请求
        HttpPost httpPost = new HttpPost(url);
        CloseableHttpResponse response = null;
        try {
            List<BasicNameValuePair> list=new ArrayList<>();
            for (Map.Entry<String, String> entry : paramMap.entrySet()) {
                list.add(new BasicNameValuePair(entry.getKey(),entry.getValue())) ;
            }
            HttpEntity httpEntity=new UrlEncodedFormEntity(list,"utf-8");

            httpPost.setEntity(httpEntity);
            // 执行请求
            response = httpclient.execute(httpPost);

            // 判断返回状态是否为200
            if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                HttpEntity entity = response.getEntity();
                String result = EntityUtils.toString(entity, "UTF-8");
                EntityUtils.consume(entity);
                httpclient.close();
                return result;
            }
            httpclient.close();
        }catch (IOException e){
            e.printStackTrace();
            return null;
        }

        return  null;
    }
}

核心思想

  • 给登录服务器留下登录痕迹

  • 登录服务器要将token信息重定向的时候,带到url地址上

  • 其他系统要处理url地址上的关键token,只要有,将token对用的用户保存到自己的session中

  • 自己系统将用户保存在自己的会话中

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值