Springboot系列之二十一:基于Redis实现的单点登录(Demo)

转自:https://blog.csdn.net/qq_22172133/article/details/82291112


一、SSO技术简介

1、基本介绍

        目前的企业应用环境中,往往有很多的应用系统,如办公自动化(OA)系统,财务管理系统,档案管理系统,信息查询系统等等。这些应用系统服务于企业的信息化建设,为企业带来了很好的效益。但是,用户在使用这些应用系统时,并不方便。用户每次使用系统,都必须输入用户名称和用户密码,进行身份验证;而且,应用系统不同,用户账号就不同,用户必须同时牢记多套用户名称和用户密码。特别是对于应用系统数目较多,用户数目也很多的企业,这个问题尤为突出。问题的原因并不是系统开发出现失误,而是缺少整体规划,缺乏统一的用户登录平台。

        SSO(Single Sign-On,单点登录)是身份管理中的一部分。SSO的一种较为通俗的定义是:SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。它包括可以将这次主要的登录映射到其他应用中用于同一个用户的登录的机制。它是目前比较流行的企业业务整合的解决方案之一。

2、SSO解决的问题

        我们在做SSO之前首先要明白为什么要有单点登录,即SSO在解决什么问题?那么我们先来看一下传统的登录实现方式:

Springboot系列之二十一:基于Redis实现的单点登录(Demo)_第1张图片

以上就是传统的登录实现方式,但是在并发量高的情况下呢?比如现在有 2000~3000 的并发,这时一个tomcat 不能满足业务需求,需要做集群。如下:

Springboot系列之二十一:基于Redis实现的单点登录(Demo)_第2张图片

那么现在就会出现Session共享的问题(tomcat做集群配置session复制。如果集群中节点很多,会形成网络风暴。推荐节点数量不要超过5个)。此外在分布式架构中,我们会把系统拆分成多个子系统,在这些子系统之间进行跳转时也会出现session不能共享的问题(和上述类似)。

        上述这些情况正是SSO要解决的问题!

3、使用SSO的好处

  • 方便用户

        用户使用应用系统时,能够一次登录,多次使用。用户不再需要每次输入用户名称和用户密码,也不需要牢记多套用户名称和用户密码。单点登录平台能够改善用户使用应用系统的体验。

  • 方便管理员

        系统管理员只需要维护一套统一的用户账号,方便、简单。相比之下,系统管理员以前需要管理很多套的用户账号。每一个应用系统就有一套用户账号,不仅给管理上带来不方便,而且,也容易出现管理漏洞。

  • 简化应用系统开发

        开发新的应用系统时,可以直接使用单点登录平台的用户认证服务,简化开发流程。单点登录平台通过提供统一的认证平台,实现单点登录。因此,应用系统并不需要开发用户认证程序。

4、实现SSO的技术

  • 基于cookies实现

        需要注意如下几点:如果是基于两个域名之间传递sessionid的方法可能在windows中成立,在unix&linux中可能会出现问题;可以基于数据库实现;在安全性方面可能会作更多的考虑。另外,关于跨域问题,虽然cookies本身不跨域,但可以利用它实现跨域的SSO。

  • Broker-based(基于经纪人)

        例如Kerberos等,这种技术的特点就是,有一个集中的认证和用户帐号管理的服务器。经纪人给被用于进一步请求的电子的身份存取。中央数据库的使用减少了管理的代价,并为认证提供一个公共和独立的”第三方”。例如Kerberos、Sesame、IBM KryptoKnight(凭证库思想)等。

  • Agent-based(基于代理人)

        在这种解决方案中,有一个自动地为不同的应用程序认证用户身份的代理程序。这个代理程序需要设计有不同的功能。比如, 它可以使用口令表或加密密钥来自动地将认证的负担从用户移开。代理人被放在服务器上面,在服务器的认证系统和客户端认证方法之间充当一个”翻译”。例如SSH等。

  • Token-based

        例如SecurID、WebID;现在被广泛使用的口令认证,比如FTP,邮件服务器的登录认证,这是一种简单易用的方式,实现一个口令在多种应用当中使用。

  • 基于安全断言标记语言(SAML)实现

        SAML(Security Assertion Markup Language,安全断言标记语言)的出现大大简化了SSO,并被OASIS批准为SSO的执行标准。开源组织OpenSAML 实现了 SAML 规范,可参考http://www.opensaml.org/。

二、SSO产品介绍

1、SUN SSO技术

1.SUM SSO介绍

        SUN SSO技术是Sun Java System Access Manager产品中的一个组成部分。

        Sun 的新身份管理产品包括Sun Java System Identity Manager、Sun Java System Directory Server Enterprise Edition 和 Sun Java System Access Manager,以上三者为Sun Java Identity Management Suite (身份识别管理套件)的组成部分,它们与Sun Java Application Platform Suite、Sun Java Availability Suite、Sun Java Communications Suite、Sun Java Web Infrastructure Suite组成Java ES。具有革新意义的这一系列产品提供端到端身份管理,同时可与 60 多种第三方资源和技术实现互操作,集成产品可以从SUN公司网站下载,一般以Agent软件方式提供,是业内集成程序最高、最为开放的身份管理解决方案之一。

        在Sun 的新身份管理产品中,Sun Java System Access Manager是基中的一个重要组成部分,Java Access Manager基于J2EE架构,采用标准的API,可扩展性强,具有高可靠性和高可用性,应用是部署在Servlets容器中的,支持分布式,容易部署且有较低的TCO。通过使用集中验证点、其于角色的访问控制以及 SSO,Sun Java System Access Manager 为所有基于 Web 的应用程序提供了一个可伸缩的安全模型。它简化了信息交换和交易,同时能保护隐私及重要身份信息的安全。

2.SUN SSO 实现原理

        SSO的核心在于统一用户认证,登录、认证请求通过IDENTITY SERVER服务器完成,然后分发到相应应用。SUN SSO是java Access Manager的一个组成部分,SSO基于Cookie实现解释如下:

(1)Policy Agent on Web or Application Server intercepts resource requests and enforces access control;

(2)Client is issued SSO token containing information for session Validation with Session service.

(3)SSO token has no content- just a long random string used as a handle.

(4)Web-based applications use browser session cookies or URL rewriting to issue SSO token.

(5)Non Web applications use the SSO API(Java/c) to obtain the SSO token to validate the users identity.

3.SUN SSO 的应用

        这里说的应用是指Sun Java System Access Manager的应用。成功应用例子很多,包括德国电信等公司的应用,国内也有大量高校在使用,也有相当多的其它行业的应用。

2、CAS技术

1.CAS 背景介绍

        CAS(Central Authentication Service),是耶鲁大学开发的单点登录系统(SSO,single sign-on),应用广泛,具有独立于平台的,易于理解,支持代理功能。CAS系统在各个大学如耶鲁大学、加州大学、剑桥大学、香港科技大学等得到应用。

        Spring Framework的Acegi安全系统支持CAS,并提供了易于使用的方案。Acegi安全系统,是一个用于Spring Framework的安全框架,能够和目前流行的Web容器无缝集成。它使用了Spring的方式提供了安全和认证安全服务,包括使用Bean Context,拦截器和面向接口的编程方式。因此,Acegi安全系统能够轻松地适用于复杂的安全需求。Acegi安全系统在国内外得到了广泛的应用,有着良好的社区环境。

2.CAS 的设计目标

  • 为多个Web应用提供单点登录基础设施,同时可以为非Web应用但拥有Web前端的功能服务提供单点登录的功能;
  • 简化应用认证用户身份的流程;
  • 将用户身份认证集中于单一的Web应用,让用户简化他们的密码管理,从而提高安全性;而且,当应用需要修改身份验证的业务逻辑时,不需要到处修改代码。

3.CAS 的实现原理

        CAS(Central Authentication Server)被设计成一个独立的Web应用。CAS创建一个位数很长的随机数(ticket)。CAS把这个ticket和成功登录的用户以及用户要访问的service联系起来。例如,如果用户peon重定向自service S,CAS创建ticket T,这个ticket T允许peon访问service S。这个ticket是个一次性的凭证;它仅仅用于peon和仅仅用于service S,并且只能使用一次,使用之后马上会过期,即ticket通过验证,CAS立即删除该ticket,使它以后不能再使用。这样可以保证其安全性。

        关于ST,在取一个ST时,即使用deleteTicket(ticketId)同时将一次性的ST删除;而对于TGT或PT,则通过resetTimer(ticketId)以更新TGT或PT的时间。在CAS服务端返回的ST中只能得出用户名。

三、实现单点登录系统

1、SSO业务流程

Springboot系列之二十一:基于Redis实现的单点登录(Demo)_第3张图片

2、SSO系统创建

        首先我们来创建SSO的Maven工程,添加依赖如下:


    
    
        org.springframework
        spring-context
    
    
        org.springframework
        spring-beans
    
    
        org.springframework
        spring-webmvc
    
    
        org.springframework
        spring-jdbc
    
    
        org.springframework
        spring-aspects
    
    
        org.springframework
        spring-context-support
    
    
        javax.servlet
        servlet-api
        provided
    
    
        javax.servlet
        jsp-api
        provided
    
    
    
        redis.clients
        jedis
    



    
        
            org.apache.tomcat.maven
            tomcat7-maven-plugin
            
                8084
                /
            
        
    

        我们这里的SSO系统主要提供两个功能模块,一个是用户的注册功能,另一个就是用户登录相关的功能。下面我们就来分别实现这两个模块。因为我们这里采用了mybatis框架,并且持久化层都是简单的增删改查操作,所以这里就直接使用Mybatis的逆向工程来生成Dao的代码了。

1.用户注册

1)数据校验接口

        Controller只是发布服务。接收三个参数,一个是要校验的数据,一个数据类型,一个是callback。调用Service校验。返回json数据。需要支持jsonp,需要判断callback。

@RequestMapping("/check/{param}/{type}")
@ResponseBody
public Object checkData(
        @PathVariable String param,
        @PathVariable Integer type,
        String callback){
    try {
        ResultObject result = registerService.checkData(param, type);
        if(StringUtils.isNotBlank(callback)){
            //请求为jsonp,需要支持
            MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
            mappingJacksonValue.setJsonpFunction(callback);
            return mappingJacksonValue;
        }
        return result;
    } catch (Exception e) {
        e.printStackTrace();
        return ResultObject.build(500, "数据校验失败");
    }
}

        Service接收两个参数,一个是要校验的数据,一个是数据类型。根据不同的数据类型生成不同的查询条件,到user表中进行查询如果查询到结果返回false,查询结果为空返回true。

public ResultObject checkData(String param, int type) {
    //根据数据类型检测数据
    TbUserExample example = new TbUserExample();
    Criteria criteria = example.createCriteria();
    //1、2、3分别代表username,phone,email-->都不可重复
    if(1==type){
        criteria.andUsernameEqualTo(param);
    }else if (2==type) {
        criteria.andPhoneEqualTo(param);
    }else if (3==type) {
        criteria.andEmailEqualTo(param);
    }
    //执行查询
    List list = userMapper.selectByExample(example);
    //判断查询结果是否为空
    if(list==null||list.isEmpty()){
        return ResultObject.ok(true);
    }
    return ResultObject.ok(false);
}

2)用户注册接口

        Controller接收一个表单,请求的方法为post。使用TbUser接收表单的内容。调用Service插入数据,返回。

@RequestMapping(value="/register",method=RequestMethod.POST)
@ResponseBody
public ResultObject register(TbUser user){
    try {
        ResultObject result = registerService.register(user);
        System.out.println(result.getStatus()+"===="+result.getMsg());
        return result;
    } catch (Exception e) {
        e.printStackTrace();
        return ResultObject.build(500, "用户注册失败");
    }
}

        Service接收TbUser参数,对数据进行校验,校验成功,插入数据,返回结果。

public ResultObject register(TbUser user) {
    //校验数据
    //校验用户名密码不能为空
    if(StringUtils.isBlank(user.getUsername())||StringUtils.isBlank(user.getPassword())){
        return ResultObject.build(400, "用户名或密码不能为空");
    }
    //校验数据是否重复
    //校验用户名
    ResultObject result = checkData(user.getUsername(), 1);
    if(!(boolean) result.getData()){
        return ResultObject.build(400, "用户名重复");
    }
    //校验手机号
    if(user.getPhone()!=null){
        result=checkData(user.getPhone(), 2);
        if(!(boolean) result.getData()){
            return ResultObject.build(400, "手机号重复");
        }
    }
    //校验邮箱
    if(user.getEmail()!=null){
        result=checkData(user.getEmail(), 3);
        if(!(boolean) result.getData()){
            return ResultObject.build(400, "邮箱重复");
        }
    }
    //插入数据
    user.setCreated(new Date());
    user.setUpdated(new Date());
    //密码MD5加密
    user.setPassword(DigestUtils.md5DigestAsHex(user.getPassword().getBytes()));
    userMapper.insert(user);
    return ResultObject.ok();
}

2.用户登录

1)用户登录接口

        Controller接收两个参数,一个是用户名,一个是密码,请求的方法为post。调用Service方法返回登录处理结果,响应json数据。

@RequestMapping(value="/user/login",method=RequestMethod.POST)
@ResponseBody
public ResultObject login(String username,String password,HttpServletRequest request,HttpServletResponse response){
    try {
        ResultObject result = loginService.login(username, password, request, response);
        return result;
    } catch (Exception e) {
        e.printStackTrace();
        return ResultObject.build(500, "登录失败");
    }
}

        Service接收用户名、密码。校验密码是否正确,生成token,向redis中写入用户信息,把token写入cookie,并在返回结果中包含token。

public ResultObject login(String username, String password,
        HttpServletRequest request, HttpServletResponse response) {
    //校验用户名密码是否正确
    TbUserExample example = new TbUserExample();
    Criteria criteria = example.createCriteria();
    criteria.andUsernameEqualTo(username);
    List list=userMapper.selectByExample(example);
    //取用户信息
    if(list==null||list.isEmpty()){
        return ResultObject.build(400, "用户名或密码错误");
    }
    TbUser user=list.get(0);
    //校验密码
    if(!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes()))){
        return ResultObject.build(400, "用户名或密码错误");
    }
    //登录成功,生成token
    String token = UUID.randomUUID().toString();
    //把用户信息写入redis
    //key:REDIS_SESSION:{TOKEN}
    //value:user转成json
    user.setPassword(null);
    jedisClient.set(REDIS_SESSION_KEY+":"+token, JsonUtils.objectToJson(user));
    //设置session过期时间
    jedisClient.expire(REDIS_SESSION_KEY+":"+token, SESSION_EXPIRE);
    //写cookie
    CookieUtils.setCookie(request, response, "PSP_TOKEN", token);
    return ResultObject.ok(token);
}

2)通过token查询用户信息

        Controller从url中取token的内容,调用Service取用户信息,响应json数据。

@RequestMapping("/user/token/{token}")
@ResponseBody
public Object getUserByToken(@PathVariable String token,String callback){
    try {
        ResultObject result = loginService.getUserByToken(token);
        if(StringUtils.isNotBlank(callback)){
            System.out.println("callback!!");
            MappingJacksonValue mappingJacksonValue=new MappingJacksonValue(result);
            System.out.println(mappingJacksonValue.toString());
            return mappingJacksonValue;
        }
        return result;
    } catch (Exception e) {
        e.printStackTrace();
        return ResultObject.build(500, "获取用户信息失败");
    }
}

        Service接收token,根据token查询redis,查询到结果返回用户对象,更新过期时间。如果查询不到结果,返回Session已经过期,状态码400。

public ResultObject getUserByToken(String token) {
    //根据token取用户信息
    String json = jedisClient.get(REDIS_SESSION_KEY+":"+token);
    //判断是否查询到结果
    if(StringUtils.isBlank(json)){
        return ResultObject.build(400, "用户session已过期");
    }
    //把json转换成java对象
    TbUser user = JsonUtils.jsonToPojo(json, TbUser.class);
    //更新session过期时间
    jedisClient.expire(REDIS_SESSION_KEY+":"+token, SESSION_EXPIRE);
    return ResultObject.ok(user);
}

2.展示注册和登录页面

        在SSO系统中只有登录注册功能,所以只需要两个页面就可以了,下面是其跳转Controller

@Controller
public class PageController {
    /**
     * 展示登录页面
     */
    @RequestMapping("/page/login")
    public String showLogin(String redirectURL,Model model){
        //需要把参数传递到jsp,页面回调
        model.addAttribute("redirect", redirectURL);
        return "login";
    }
 
    /**
     * 展示注册页面
     */
    @RequestMapping("/page/register")
    public String showRegister(){
        return "register";
    }
}

        我们这里为了解决登录回调,在登录页面的js实现如下:

var redirectUrl = "${redirect}";
var LOGIN = {
        checkInput:function() {
            if ($("#loginname").val() == "") {
                alert("用户名不能为空");
                $("#loginname").focus();
                return false;
            }
            if ($("#nloginpwd").val() == "") {
                alert("密码不能为空");
                $("#nloginpwd").focus();
                return false;
            }
            return true;
        },
        doLogin:function() {
            $.post("/user/login", $("#formlogin").serialize(),function(data){
                if (data.status == 200) {
                    alert("登录成功!");
                    if (redirectUrl == "") {
                        location.href = "http://www.psp.com";
                    } else {
                        location.href = redirectUrl;
                    }
                } else {
                    alert("登录失败,原因是:" + data.msg);
                    $("#loginname").select();
                }
            });
        },
        login:function() {
            if (this.checkInput()) {
                this.doLogin();
            }
        }   
};
$(function(){
    $("#loginsubmit").click(function(){
        LOGIN.login();
    });
});

3、其他系统整合SSO

        现在我们就来演示其他系统对SSO进行整合,这里就以门户系统整合SSO为例。

1.门户登录

        当用户在首页点击登录或者注册的时候需要跳转到sso系统。进行相应的操作。登录成功跳转到首页。首页应该显示当前登录的用户。首先门户系统的登录按钮代码如下,

登录

只是一个简单的超链接,跳转到SSO登录页面,并进行相关的登录操作,当登录完成后,在首页展示用户。其前端实现如下,

checkLogin : function(){
    var _ticket = $.cookie("PSP_TOKEN");
    if(!_ticket){
        return ;
    }
    $.ajax({
        url : "http://sso.psp.com/user/token/" + _ticket,
        dataType : "json",
        type : "GET",
        success : function(data){
            if(data.status == 200){
                var username = data.data.username;
                var html = username + ",您好!";
                $("#loginbar").html(html);
            }
        }
    });
}

2.登录拦截器

        在Poratl系统中,对于有些页面是需要登录之后才能访问的,比如订单页面,当用户查看订单页面时此时必须要求用户登录,可以使用拦截器来实现。拦截器的处理流程为:

  • 拦截请求url
  • 从cookie中取token
  • 如果没有toke跳转到登录页面。
  • 取到token,需要调用sso系统的服务查询用户信息。
  • 如果用户session已经过期,跳转到登录页面
  • 如果没有过期,放行。

 其中拦截器配置如下,拦截order下的所有操作

 
    
        
        
    
 

然后在springmvc中需要实现HandlerInterceptor接口。

public class LoginInterceptor implements HandlerInterceptor {
​
    @Autowired
    private UserService userService;
    @Value("${SSO_LOGIN_URL}")
    private String SSO_LOGIN_URL;
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object object) throws Exception {
        // 1、拦截请求url
        // 2、从cookie中取token
        // 3、如果没有toke跳转到登录页面。
        // 4、取到token,需要调用sso系统的服务查询用户信息。
        TbUser user = userService.getUserByToken(request, response);
        // 5、如果用户session已经过期,跳转到登录页面
        if (user == null) {
            response.sendRedirect(SSO_LOGIN_URL+"?redirectURL="+request.getRequestURI());
            return false;
        }
        //把用户对象放入request中
        request.setAttribute("user", user);
        // 6、如果没有过期,放行。
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest arg0,
            HttpServletResponse response, Object object, Exception exception)
            throws Exception {
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
            Object object, ModelAndView modelAndView) throws Exception {
    }
}

其对应的Service作用为,根据token取用户信息,如果取到返回TbUser对象,如果取不到,返回null。

@Service
public class UserServiceImpl implements UserService {
​
    @Value("${SSO_BASE_URL}")
    private String SSO_BASE_URL;
    @Value("${SSO_USER_TOKEN_SERVICE}")
    private String SSO_USER_TOKEN_SERVICE;
 
 
    @Override
    public TbUser getUserByToken(HttpServletRequest request,
            HttpServletResponse response) {
        try {
            //从cookie中获取token
            String token = CookieUtils.getCookieValue(request, "PSP_TOKEN");
            //判断token是否有值
            if(StringUtils.isBlank(token)){
                return null;
            }
            //调用sso的查询服务
            String json = HttpClientUtil.doGet(SSO_BASE_URL+SSO_USER_TOKEN_SERVICE+token);
            //把json转换成java对象
            ResultObject result = ResultObject.format(json);
            if(result.getStatus()!=200){
                return null;
            }
            //取用户对象
            result = ResultObject.formatToPojo(json, TbUser.class);
            TbUser user=(TbUser) result.getData();
            return user;
        } catch (Exception e) {
            return null;
        }
    }
}

你可能感兴趣的:(SpringBoot)