单点登录系统SSO解析及开发小结           返回主页

在分布式系统架构下是每一个系统都是由一个团队进行维护,每个系统都是单独部署运行一个单独的应用容器如Tomcat,所以,不能将用户的登录信息保存到session中。

多个tomcat的session一般是不能共享的,虽然我们可以利用tomcat自身的session同步功能,但随着机器和业务量增加,效率会越来越低。而且这样做会使业务和Tomcat严重耦合,不利于扩展,所以我们需要一个单独的系统来维护用户的登录信息,这个系统就是SSO即单点登录系统。

单点登录是一个热门话题,是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。

百度百科是这样解释单点登录的。

./sso-img/sso-1.png

传统的All-in-one型应用的认证系统是和业务系统集合在一起的,当用户认证通过时,将用户信息存入session中。其他业务只需要从业务中通过对应的key取到用户信息进行相关业务处理即可。它的流程图是这样的。

./sso-img/sso-2.png

单点登录系统由于其特殊性,不能使用容器的session,只能由用户自己基于session的性质重新开发一套有状态的会话保持系统。它的流程图如下。

./sso-img/sso-3.png

解释

传统的session是将用户信息存入内存,维护一个哈希表。每一次请求携带JSESSIONID到服务端,根据此JSESSIONID查找到对应的用户信息。

由此出发我们想到可以利用Redis等内存数据库进行用户信息的存储,自定义Token生成规则将用户信息写入Redis中。这样将用户信息的存储和业务系统进行拆分,使系统更加健壮,更易于扩展。新加的系统只需要从SSO中获取相关的认证即可进行横向的业务扩展。而且Redis本身的性质也易于进行集群化的部署。

如何开发

最近在做毕设,通过Dubbox对业务进行了拆分,多个系统均需要用到用户登录信息;而且这些业务系统部署在不同的web应用容器中,因此开发一套SSO就成为自然而然的事情。

建立工程,包结构如下

./sso-img/sso-4.png

核心框架采用了Spring4+mybatis3+springMVC,这里为了避免文章过于冗长,我就不贴所有的代码了,只贴关键代码。在随后的总结文章我会给出整个工程的github地址及如何部署。

UserController 用户登录注册相关控制器

    package com.blog.sso.controller;

    ...省略import代码

    @RequestMapping("user")
    @Controller
    public class UserController {

        //登录成功跳转页面
        private static final String INDEX_PAGE = "http://www.blog.com/index.html";
        //cookie键名称
        private static final String COOKIE_NAME = "LifeSharing_User_Token";

        @Autowired
        UserService userService;

        ...省略页面跳转相关代码

        ...省略注册action

        /*登录action*/
        @RequestMapping(value = "doLogin", method = RequestMethod.POST)
        public String doLogin(@RequestParam("userEmail") String userEmail, 
                @RequestParam("userPwd") String userPwd,
                HttpServletRequest request,
                HttpServletResponse response) {
            if (StringUtil.isEmpty(userEmail) || StringUtil.isEmpty(userPwd)) {
                request.setAttribute("userEmail", userEmail);
                request.setAttribute("loginMsg", "请输入正确的用户名及密码");
                return "login";
            } else {
                //组装用户数据验证数据合法性
                User user = User.builder().userEmail(userEmail).userPwd(userPwd).build();
                //获取登录成功后的Token
                String token = null;
                try {
                    token = userService.isUserLogin(user);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                if (null != token) {
                    //查询到用户所有数据
                    User userInfo = this.userService.queryUserByUseremail(userEmail);
                    //被拉黑无法登陆
                    if (1 == userInfo.getIsBanned()) {
                        request.setAttribute("loginMsg", "您已被拉黑,请联系管理员");
                        return "login";
                    }
                    //登录成功,保存token到cookie中
                    CookieUtils.setCookie(request, response, COOKIE_NAME, token);
                    //request.getSession().setAttribute("user", userInfo);
                    return "redirect:" + INDEX_PAGE;
                } else {
                    //数据回显
                    request.setAttribute("userEmail", userEmail);
                    request.setAttribute("loginMsg", "用户名或密码错误");
                    return "login";
                }
            }
        }
    }

核心在于登录业务的处理。

1. 首先对前台登录表单中传来的用户名及密码进行验证,如果输入合法,则生成该用户对应的Token,生成规则由开发者自行制定。这里我将用户的邮箱和当前的时间合并进行MD5哈希。

    String token = DigestUtils.md5Hex(user.getUserEmail() + System.currentTimeMillis());

2. 将token及Json序列化后的用户信息json串写入Redis中,此处的RedisService是自行封装的Redis操作类,与Spring进行集成。

    redisService.set("TOKEN_" + token, MAPPER.writeValueAsString(userInfo), REDIS_TIME);

3. 在controller中将token写入到cookie中,CookieUtils是自行封装的设置cookie工具类

    CookieUtils.setCookie(request, response, COOKIE_NAME, token);

    ================================================================
    /**
     * 设置Cookie的值,并使其在指定时间内生效
     * 
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(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) {
            logger.error("Cookie Encode Error.", e);
        }
    }

下图为cookie中的token信息

./sso-img/sso-5.jpg

4. 跳转到业务系统。在这里需要和后端的服务接口进行对接,从Redis根据cookie中的token值取出对应的用户数据并写入当前的session中

    ssoUserQueryService.queryUserByToken(USER_TOKEN).toString())

这里的ssoUserQueryService是dubbox中注册的单点接口,根据对应的Token获取用户的序列化json数据并反序列化为User对象。

实现类如下:通过redisService工具类将token注入并调用Jedis的get方法拿到对应的Json字符串;然后通过Jackson的ObjectMapper中的readValue方法将json串反序列化为对象并返回

    @Service
    public class SSOUserQueryServiceImpl implements ISSOUserQueryService {

        private static final Logger LOGGER = Logger.getLogger(SSOUserQueryServiceImpl.class);

        //Jackson映射类
        private static final ObjectMapper MAPPER = new ObjectMapper();  
        //会话超时时间
        private static final Integer REDIS_TIME = 60 * 30;

        @Autowired
        private RedisService redisService;

        //通过用户token查询用户信息
        @Override
        public User queryUserByToken(String token) {
            //1.通过Token的key取得序列化用户信息
            String userJsonData = redisService.get(token);
            LOGGER.info(token);
            if (StringUtils.isEmpty(userJsonData)) {
                //登录超时
                return null;
            }
            //2.重设Redis生存时间
            this.redisService.expire(token, REDIS_TIME);
            //3.反序列化Json为对象
            try {
                User userFromJson = MAPPER.readValue(userJsonData, User.class);
                return userFromJson;
            } catch (Exception e) {
                e.printStackTrace();
            } 
            return null;
        }
    }   

5. 在业务系统中取出对应的用户数据

@CookieValue这个注解用于从cookie中取出对应key的value,也就是之前存入的cookie值(Token)

通过token调用ssoUserQueryService的queryUserByToken(USER_TOKEN)方法查出Redis中的用户数据设置到本容器的session中

如果SSO需要集群部署,访问到的每一台tomcat都能携带相同的用户数据,从而使得用户数据同步。当然需要考虑统一注销的功能的实现,这里可以采用MQ进行发布订阅形式的调用,超出了本文的范围先不谈。

然后将页面的数据放到Map中并跳转到前端界面中进行展现

代码如下

    @RequestMapping(value = "index", method = RequestMethod.GET)
    public String toLogin(
                    @CookieValue(value = "LifeSharing_User_Token") String LS_User_Token ,
                    HttpServletRequest request,
                    HttpSession session) throws Exception {
        /*
         * 获取cookie中的值
         * 先判断Redis中是否有用户信息
         * @CookieValue 通过设置key取得对应的cookie值
         */
        USER_TOKEN = "TOKEN_" + LS_User_Token;
        //未登录访问主页或者退出后访问均重定向到登录页
        if (StringUtils.isEmpty(LS_User_Token) || (null == redisService.get(USER_TOKEN))) {
            return "redirect:" + LOGIN_PAGE;
        } else {
            //1.通过Token的key取得序列化用户信息
            String userInfoJsonSerializable = redisService.get(USER_TOKEN);
            //2.反序列化Json为对象
            User userFromJson = ssoUserQueryService.queryUserByToken(USER_TOKEN);

            //将该用户设置到Session
            session.setAttribute("user", userFromJson);
            //从序列化后的对象中取得用户的ID
            Integer userID = userFromJson.getU_id();
            //页面加入数据再返回
            Map<String, Object> indexDataMap = blogService.getIndexDataMap(userID);

            List<GuanzhuQueryVO> guanzhuQueryVOs = (List<GuanzhuQueryVO>)indexDataMap.get("guanzhuList");
            //TODO 删掉此处日志并将数据传到前台
            LOGGER.info(guanzhuQueryVOs.toString());
            @SuppressWarnings("unchecked")
            //博客列表数据
            List<BlogQueryVO> blogs = (List<BlogQueryVO>) indexDataMap.get("bloglist");

            request.setAttribute("bloglist", blogs);
            return "index";
        }

页面效果如图

./sso-img/sso-6.jpg

Redis中的序列化用户数据如图(使用Redis Desktop Manager工具进行查询)

./sso-img/sso-7.jpg

PS:注销功能

    @RequestMapping(value = "logout", method = RequestMethod.GET)
        public String adminLogout(HttpServletRequest request) {
            if (null != request.getSession()) {
                //移除会话信息
                request.getSession().removeAttribute("user");
                //移除Redis中用户信息
                redisService.del(USER_TOKEN);
            }
            return "redirect:" + LOGIN_PAGE;
        }

首先移除本容器中的会话信息,同时删除Redis中的本用户的序列化信息。这样做能够有效避免直接通过url绕过登录访问系统,这么做则直接跳转到SSO登录页面。

小结

单点登录系统是分布式应用中不可或缺的一环,对于解耦应用及增强应用的健壮性有着重要的作用。希望本文能够对想要开发SSO系统的读者有所启迪。

推荐一篇很不错的SSO理论解释文章

http://www.cnblogs.com/ywlaker/p/6113927.html