文章目录
  1. 1. 解释
  2. 2. 如何开发
    1. 2.1. UserController 用户登录注册相关控制器
  3. 3. 核心在于登录业务的处理。
  4. 4. 代码如下
  5. 5. PS:注销功能
  6. 6. 小结
    1. 6.1. 推荐一篇很不错的SSO理论解释文章

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

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

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

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

img/sso-1.png

传统的All-in-one型应用的认证系统是和业务系统集合在一起的,当用户认证通过时,将用户信息存入session中。其他业务只需要从业务中通过对应的key取到用户信息进行相关业务处理即可。它的流程图是这样的。
img/sso-2.png
单点登录系统由于其特殊性,不能使用容器的session,只能由用户自己基于session的性质重新开发一套有状态的会话保持系统。它的流程图如下。
img/sso-3.png

解释

传统的session是将用户信息存入内存,维护一个哈希表。每一次请求携带JSESSIONID到服务端,根据此JSESSIONID查找到对应的用户信息。
由此出发我们想到可以利用Redis等内存数据库进行用户信息的存储,自定义Token生成规则将用户信息写入Redis中。这样将用户信息的存储和业务系统进行拆分,使系统更加健壮,更易于扩展。新加的系统只需要从SSO中获取相关的认证即可进行横向的业务扩展。而且Redis本身的性质也易于进行集群化的部署。

如何开发

最近在做毕设,通过Dubbox对业务进行了拆分,多个系统均需要用到用户登录信息;而且这些业务系统部署在不同的web应用容器中,因此开发一套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信息
    img/sso-5.jpg

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

这里的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;
    }
}    
  1. 在业务系统中取出对应的用户数据
    @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";
    }

页面效果如图
img/sso-6.jpg
Redis中的序列化用户数据如图(使用Redis Desktop Manager工具进行查询)
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

文章目录
  1. 1. 解释
  2. 2. 如何开发
    1. 2.1. UserController 用户登录注册相关控制器
  3. 3. 核心在于登录业务的处理。
  4. 4. 代码如下
  5. 5. PS:注销功能
  6. 6. 小结
    1. 6.1. 推荐一篇很不错的SSO理论解释文章
Fork me on GitHub