分布式架构的单点登陆

1.需要实现的功能

①用户在任意模块进行登陆操作后,在系统中其它模块能查询到当前登陆用户的信息

②用户在登陆过一次系统后,应该记忆用户的用户名,下次不必登陆就可以在页面显示用户名,但是实际状态是未登录,且有过期时间

③用户在任意页面的登录按钮,登录后能回到原来的页面.


2.实现的思路

登陆服务单独创建一个用户登陆认证中心模块(分布式,SSO(single sign on)模式

对于第一条:①用户在任意模块进行登陆操作后,在系统中其它模块能查询到当前登陆用户的信息

这个是单点登录的最基本的功能,可以将用户登陆后的信息放在redis缓存中,每次用户登陆就设到缓存中,采用redis的str数据类型.key可以为user:{userId}:info,value可以设置为用户信息的json字符串.

服务中登陆的方法:

//key的前缀
public final String userKey_prefix = "user:";
//key的后缀
public final String userinfoKey_suffix=":info";
//过期时间 一个小时
public final int userKey_timeOut=60*60;

public UserInfo login(UserInfo userInfo) {
        //密码的加密 DigestUtils是加密的工具类
        userInfo.setPasswd(DigestUtils.md5DigestAsHex(userInfo.getPasswd().getBytes()));
        //从数据库中查找用户是否存在
        UserInfo loginUserInfo = userInfoMapper.selectOne(userInfo);
        if(loginUserInfo!=null){
            //redisUtil也是操作redis的工具类
            Jedis jedis = redisUtil.getJedis();
            String key = userKey_prefix+loginUserInfo.getId()+userinfoKey_suffix;
            //保存到redis中
            jedis.setex(key, userKey_timeOut, JSON.toJSONString(LoginUserInfo));
            jedis.close();
        }
        return loginUserInfo;
    }

当其他模块需要用户的登录信息时,就可以根据用户的userId直接去redis中查找.

//根据userId进行登陆认证
    public UserInfo verify(String userId){
        //去缓存中查询是否有对应userId的用户信息
        Jedis jedis = redisUtil.getJedis();
        //拼接key值前缀+userId+后缀
        String key = userKey_prefix+userId+userinfoKey_suffix;
        String userInfoJson = jedis.get(key);
        if(userInfoJson!=null&&userInfoJson.trim().length()>0){
            //给这个key重新设置失效时间
            jedis.expire(key, userKey_timeOut);
            UserInfo userInfo = JSON.parseObject(userInfoJson, UserInfo.class);
            return userInfo;
        }
        return null;
    }

对于第二三条:

②用户在登陆过一次系统后,应该记忆用户的用户名,下次不必登陆就可以在页面显示用户名,但是实际状态是未登录,且有过期时间.

③用户在任意页面的登录按钮,登录后能回到原来的页面.

这两个需求需要利用浏览器的cookie功能或者localStorage本地存储功能.这里只说利用cookie来实现.并利用一个简单的token机制

具体做法为:

  1. 用userId+当前用户登录ip地址+密钥生成token
  2. 重定向用户到之前的来源地址,同时把token作为参数附上。
  3. 将token设置到用户的浏览器cookie中,并设置一个过期时间,通过解密token就可以实现第二个需求

首先页面的所有登陆入口应该用js代码来控制,每次点击登陆按钮登陆时,应该带上一个originUrl的参数,用于保存登陆后需要跳转的页面控制器,一般设置为当前页面

function login(){
      //使用encodeURIComponent()函数对url进行编码
      var s = encodeURIComponent("http://xxx.xxx.com/cartList");
       window.location.href="http://passport.xxx.com/index?originUrl="+s;
    }

           然后进入登陆页面的跳转控制器,将originUrl保存到request域中

@RequestMapping("/index")
    public String passport(HttpServletRequest request){
        String originUrl = request.getParameter("originUrl");
        // 保存上
        request.setAttribute("originUrl",originUrl);
        return "index";
    }

          在登陆页面的登陆表单提交方法中将originUrl取出,如果登陆成功就跳转到相应的页面跳转控制器

$.post("/login",$("#loginForm").serialize(),function (data) {
                        //登陆的回调函数
                        if(data&&data!='fail'){
                            var originUrl = $("#originUrl").val();
                            console.log("originUrl:"+originUrl);
                            if(originUrl==''){
                                //如果没有回跳页面就跳转到首页
                                window.location.href="http://www.xxx.com?newToken="+data;
								return ;
                            }
                               //解码
                              originUrl = decodeURIComponent(originUrl);
                            var idx=originUrl.indexOf('?');

                            if(idx<0){
                                originUrl+='?'
                            }else{
                                originUrl+='&'
                            }
                            //拼接newToken token在登陆控制器"/login"中生成,并返回给页面使用
                            window.location.href=originUrl+"newToken="+data;
                            return ;
                        }else{
                            $(".error").text("用户名密码错误!");
                            $(".error").show();
                        }
                    }   );

     在登陆控制器"/login"中生成token.我们在这里使用JWT(Json Web Token)工具.

简单的介绍一下JWT工具:

      JWT(Json Web Token) 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准.

      JWT 最重要的作用就是对 token信息的防伪作用。

     JWT的原理:

      一个JWT由三个部分组成:公共部分、私有部分、签名部分。最后由这三者组合进行base64编码得到JWT。

      

         公共部分

主要是该JWT的相关配置参数,比如签名的加密算法、格式类型、过期时间等等。

         私有部分

用户自定义的内容,根据实际需要真正要封装的信息。

          签名部分

根据用户信息+盐值+密钥生成的签名。如果想知道JWT是否是真实的只要把JWT的信息取出来,加上盐值和服务器中的密钥就可以验证真伪。所以不管由谁保存JWT,只要没有密钥就无法伪造。

          这里使用服务器IP作为盐值

         用户信息+ip=密钥:

         iP:当前服务器的Ip地址!

         base64编码,并不是加密,只是把明文信息变成了不可见的字符串。但是其实只要用一些工具就可以吧base64编码解成明文,所以不要在JWT中放入涉及私密的信息,因为实际上JWT并不是加密信息

使用JWT需要在项目中加入依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

制作一个JWT的工具类

public class JwtUtil {
    //生成token
    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;

    }

    //解密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;
    }
}

"/login"控制器

    @RequestMapping("/login")
    @ResponseBody
    public String login(UserInfo userInfo,HttpServletRequest request){
        //获取服务器ip地址 需要在nginx配置文件中的反向代理配置中配置一个参数才能使用
        String remoteAddr  = request.getHeader("X-forwarded-for");
        if(userInfo!=null){
            //调用服务中的login方法
            UserInfo loginUser = userInfoService.login(userInfo);
            if(loginUser!=null){
            //生成token
                Map<String,Object> map = new HashMap<>();
                map.put("userId", loginUser.getId());
                map.put("nickName", loginUser.getNickName());
                //signKey是一个公钥参数,可定一个常量或者在配置文件中设置
                String token = JwtUtil.encode(signKey, map, remoteAddr);
                return token;
            }
        }
        return "fail";
    }

       这样,就实现了在任意页面跳转到登陆页面后,再回到原来的页面,并携带了我们设置的token参数,用于解密用户信息

       但是还差最关键的一步,将token设置到用户浏览器的cookie中

       因为用户在访问系统时,有些页面或者功能需要登陆才能访问,有些不需要登陆,为了方便管理.我们做个拦截器.拦截所有的请求,在拦截器中判断是否需要登陆.而我们的token就在拦截器里设置到cookie中.

        首先自定义一个拦截器: 

        拦截器的配置:

@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter{
    @Autowired
    //接下来要完成的自定义拦截器
    AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry){
        //将自定义拦截器增加到springmvc的拦截器集合中,并配置拦截的路径为所有("/**")
        registry.addInterceptor(authInterceptor).addPathPatterns("/**");
        super.addInterceptors(registry);
    }
}

        自定义拦截器:可以继承HandlerInterceptorAdapter也可以实现HandlerInterceptor接口

        HandlerInterceptor拦截器接口中有三个方法preHandler,postHandler,afterCompletion,不熟悉执行时机的回去补课

@Component
public class AuthInterceptor extends HandlerInterceptorAdapter {

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

        //可能存在刚登陆还没有写到cookie中的token
        String token = request.getParameter("newToken");
        //如果第一步获取了token 说明刚登陆还没有写到cookie中 要在这里写入
        if(token!=null){
            CookieUtil.setCookie(request, response, "token", token, WebConst.COOKIE_MAXAGE, false);
        }

        //如果第一步没有获取token 说明不是刚登陆 url中没有newToken 或者根本就没有登陆  这里进行判断是哪种情况
        if(token==null){
            //尝试从cookie中获取token 第三个参数为是否进行UTF-8解码 如果涉及中文
            token = CookieUtil.getCookieValue(request, "token", false);
        }
           //从cookie中获取到了token 可能是之前登陆的保存在浏览器cookie中的token也可能是已经登陆了设置到cookie中的token 后边还需要认证去redis中认证判断
        if (token!=null){
            // 解密token
            Map map = getUserMapByToken(token);
            // map.get(); 取得到nickName
            String nickName = (String) map.get("nickName");
            // 将用户的昵称,保存到作用域中,为了在页面显示
            request.setAttribute("nickName",nickName);
        }

        //判断当前请求是否需要登陆才能访问 这里使用了一个自定义注解 用于标识控制器方法是否需要登陆才能访问
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        LoginRequire methodAnnotation = handlerMethod.getMethodAnnotation(LoginRequire.class);
        if(methodAnnotation!=null){
            //说明标识了自定义注解
            //获取服务器ip地址 用于解密token
            String remoteAddr = request.getHeader("x-forwarded-for");
            // 认证方法在passportController中,需要使用远程调用认证 后边会贴代码
            String result = HttpClientUtil.doGet(WebConst.VERIFY_ADDRESS + "?token=" + token + "&currentIp=" + remoteAddr);
            if ("success".equals(result)){
                // 成功,从token中解密用户信息(用户id和用户昵称)
                Map map = getUserMapByToken(token);
                String userId = (String) map.get("userId");
                //保存userId到request域中,很重要,用于需要登陆才能访问的控制器取得用户信息!
                request.setAttribute("userId",userId);
                return true;
            }else {
                if (methodAnnotation.autoRedirect()){
                    //自定义注解可设置为需要登陆访问  也可设置为不需要登陆
                    //如果设置了需要登陆  但是认证没有通过就需要重定向到登陆页面
                    redirectLogin(request,response);
                    return false;
                }
            }
        }

        return true;
    }

    //重定向到登陆界面
    private void redirectLogin(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String requestURL = request.getRequestURL().toString();
        String encodeURL  = URLEncoder.encode(requestURL, "UTF-8");
        response.sendRedirect(WebConst.LOGIN_ADDRESS+"?originUrl="+encodeURL);
    }

    //从token中解密用户信息(用户id和用户昵称)
    private Map getUserMapByToken(String token) {
        String tokenUserInfo  = StringUtils.substringBetween(token, ".");
        Base64UrlCodec base64UrlCodec = new Base64UrlCodec();

        byte[] decode = base64UrlCodec.decode(tokenUserInfo);

        String tokenJson = null;
        try{
            tokenJson = new String(decode,"UTF-8");
        }catch(Exception e){
            e.printStackTrace();
        }
        Map map = JSON.parseObject(tokenJson, Map.class);
        return map;
    }
}

自定义注解:

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

    boolean autoRedirect() default true;
}

         远程登陆认证服务控制器:其实就是解密token然后到redis中根据解密得到的用户id查找是否已经在缓存中,如果在就说明已经登陆了,不在就说明还没有登陆,或者登陆已经过期,需要重新登陆

登陆认证的控制器"/verify"

//登录验证
    @RequestMapping("/verify")
    @ResponseBody
    public String verify(HttpServletRequest request){
        String token = request.getParameter("token");
        String currentIp = request.getParameter("currentIp");
        //用JWT工具类解密token
        Map<String, Object> map = JwtUtil.decode(token, signKey, currentIp);
        if(map!=null&&map.size()>0){
            String userId = (String) map.get("userId");
            //拿到后台redis中认证
            UserInfo userInfo = userInfoService.verify(userId);
            if(userInfo!=null){
                return "success";
            }
        }
        return "fail";
    }

    后台认证代码:前面已经有了,这里再贴一次

//根据userId进行登陆认证
    public UserInfo verify(String userId){
        //去缓存中查询是否有对应userId的用户信息
        Jedis jedis = redisUtil.getJedis();
       
        String key = userKey_prefix+userId+userinfoKey_suffix;
        String userInfoJson = jedis.get(key);
        if(userInfoJson!=null&&userInfoJson.trim().length()>0){
            //给这个key重新设置失效时间
            jedis.expire(key, userKey_timeOut);
            UserInfo userInfo = JSON.parseObject(userInfoJson, UserInfo.class);
            return userInfo;
        }
        return null;
    }

     至此,一个完整的单点登陆(SSO)已经实现,并且在别的需要登陆才能访问的控制器上,直接加上@LoginRequire(autoRedirect=true)注解即可.\

           只要通过登陆认证了,就可以直接在控制器方法里从request域中获取用户的userId

后边两个需求在之后的电商购物车模块中完成

④当在未登录状态下进行购物车的结算时,需要先跳转到登陆页面,登陆后不必回退到购物车列表,直接进入订单页面,

⑤当在未登录状态下的购物车列表,点击登陆进行登陆时,应该进行购物车的合并,并回到原来的购物车列表页面

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值