小小千想和您聊一聊

当前位置: 首页> 技术分享> SSO单点登录方案

SSO单点登录方案

  前言: 单点登录其实是一个概念,主要是为了解决一次登录,多系统(本系统或外部系统)之间不需要重复登录的问题,就目前来说,主流的解决方案针对业务场景分为3个方向:

  1: 同一公司,同父域下的单点登录解决方案.

  如[map.baidu.com] [www.baidu.com] [image.baidu.com]

  基于cookie开源项目代表: JWT(https://jwt.io/);会详细介绍和实现;

  2: 同一公司,不同域下的单点登录解决方案.

  如[www.taobao.com] [www.tmall.com]

  基于中央认证服务器开源项目代表:CAS(https://github.com/apereo/cas); https://www.apereo.org/projects/cas

  3: 不同公司之间,不同域下的 第三方登录功能实现.

  如第三方网站支持qq登录,微信登录,微博登录等;

  基于OAuth2.0协议各大公司自己的支持;

  一: 基于JWT的单点登录解决方案:

  首先我们看一下图形,这个就是基于JWT解决方案的单点登录解决方案;

  应用和原理 : 什么是JWT,什么业务场景使用JWT?

JWT: <https://jwt.io/>

  JSON Web Tokens are an open, industry standard RFC 7519 method for representing **claims** securely between two parties.

  JSONWeb令牌是一种开放的、行业标准的RFC7519方法,用于在双方之间安全地表示**声明**。

  JWT 应用场景:

  Authorization (授权) : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用,JWT 省去了服务器存储用户信息的过程。

  Information Exchange (信息交换) : 对于安全的在各方之间传输信息而言,JSON Web Tokens无疑是一种很好的方式。因为JWTs可以被签名,例如,用公钥/私钥对,你可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,您还可以验证内容没有被篡改。

  JWT的令牌结构 看官方文档https://jwt.io/introduction/ 并解释

  JSON Web Token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:Header,Payload,Signature。

  官网对于这个三部分的定义。一个典型的JWT Token是 tttttttt.yyyyyyyyyyy.eeeeeee 这样组成。

  Header : 由两部分组成:token的类型(“JWT”)和算法名称(比如:HMAC SHA256或者RSA等等)。然后,用Base64对这个JSON编码就得到JWT的第一部分。

  Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。分为三种类型

  1:registered claims。标准声明。一般常用于校验的有 iat,exp,nbf 校验 token 是否过期。

  /*

  iss: jwt签发者

  sub: jwt所面向的用户

  aud: 接收jwt的一方

  exp: jwt的过期时间,这个过期时间必须要大于签发时间

  nbf: 定义在什么时间之前,该jwt都是不可用的.

  iat: jwt的签发时间

  jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

  */

  2:public claims。公开声明。 可以存放任意信息,比如userid等。不是信息安全的。

  3:private claims。私密声明。 双方约定的信息。

  Signature Base64(header).Base64(payload) + head中定义的算法 + 密钥 生成一个字符串 。

  使用方式:

  客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

  此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

  // Authorization: Bearer

  另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

  通过代码实现JWT之单点登录(基于springboot+dubbo+zookeeper)

  代码实现JWT服务器(基于dubbo实现):

JWTService:
package com.qianfeng.user;

import com.qianfeng.user.dto.UserRequest;
import com.qianfeng.user.dto.UserResponse;

/**
 * 用户服务对外接口
 *
 * @author  Martin
 *
 */
public interface IUserService {


    /**
     * 用户登录接口
     * @param  request user info
     * @return 查询到的结果
     */
    UserResponse login(UserRequest request);


}
JWT ServiceImpl:

package com.qianfeng.user;

import com.qianfeng.user.dao.entity.UserEntity;
import com.qianfeng.user.dao.mapper.UserMapper;
import com.qianfeng.user.dto.UserRequest;
import com.qianfeng.user.dto.UserResponse;
import com.qianfeng.user.util.JWTTokenUtil;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

/**
 *
 * 用户业务实现类
 * @author  Martin
 */
@Service("userServiceImpl")
public class UserServiceImpl implements IUserService{

    @Autowired
    private UserMapper userMapper;

    /**
     * 用户登录方法实现
     * @param  request 请求参数
     * @return 登录结果
     */
    public UserResponse login(UserRequest request) {

        //非空校验
        UserResponse ur = verification(request);
        if(null != ur){
            return ur;
        }

        UserEntity ue = new UserEntity();
        ue.setUserName(request.getUserName());
        ue.setUserPass(request.getUserPass());

        //执行登录
        UserEntity user = userMapper.login(ue);

        ur = new UserResponse();

        if(null == user){ //登录失败
            ur.setCode(ErrorCode.CODE_LOGIN_FAIL);
            ur.setMsg(ErrorCode.MSG_LOGIN_FAIL);
            return ur;
        }

        //获取token
        Map<String,Object> map=new HashMap<>();
        map.put("uid",user.getUserID());
        map.put("exp", DateTime.now().plusSeconds(30).toDate().getTime()/1000); //设置过期时间 当前时间的基础上加上30s

        String token = JWTTokenUtil.generatorToken(map); //通过封装的算法来获取token
        ur.setToken(token);


        ur.setUid(user.getUserID());//将一些用户信息返回,可以封装一个通用类

        return ur;
    }

    //非空校验
    private UserResponse verification(UserRequest request){
        UserResponse ur = null;

        //请求为空
        if(null == request){
            ur = new UserResponse();
            ur.setCode(ErrorCode.CODE_ERROR_SYSTEM);
            ur.setMsg(ErrorCode.MSG_ERROR_SYSTEM);
            return ur;
        }

        //用户名为空
        if(StringUtils.isEmpty(request.getUserName())){
            ur = new UserResponse();
            ur.setCode(ErrorCode.CODE_USER_EMPTY);
            ur.setMsg(ErrorCode.MSG_USER_EMPTY);
            return ur;
        }

        //密码为空
        if(StringUtils.isEmpty(request.getUserPass())){
            ur = new UserResponse();
            ur.setCode(ErrorCode.CODE_PASS_EMPTY);
            ur.setMsg(ErrorCode.MSG_PASS_EMPTY);
            return ur;
        }

        return ur;
    }
}
JWT Util:
package com.qianfeng.user.util;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Map;

/**
 * JWT 生成token和提取token信息工具类
 * @author Martin
 */
public class JWTTokenUtil {

    static SignatureAlgorithm sa = SignatureAlgorithm.HS256;//使用hs256算法

    //获取key
    private static Key generatorKey(){
        byte[] bin=DatatypeConverter.parseBase64Binary("2521afcf18c749c1a8a7615c03d15e43");
        Key key=new SecretKeySpec(bin,sa.getJcaName());
        return key;
    }

    /**
     * 将传过来的信息按照 Header,Payload,Signature 的方式组装一个字符串
     * @param payLoad
     * @return
     */
    public static String generatorToken(Map<String,Object> payLoad){
        ObjectMapper objectMapper=new ObjectMapper();

        try {

            return Jwts.builder().setPayload(objectMapper.writeValueAsString(payLoad))
                    .signWith(sa,generatorKey()).compact();

        } catch (JsonProcessingException e) {

            e.printStackTrace();
        }
        return null;
    }


    /**
     * 将token解析,得到Payload内容
     * @param token
     * @return
     */
    public static Claims phaseToken(String token){

        //将token解析成claims
        Jws<Claims> jws = Jwts.parser().setSigningKey(generatorKey()).parseClaimsJws(token);

//        jws.getHeader();    Header
//        jws.getBody();      Payload
//        jws.getSignature(); Signature


        return jws.getBody();
    }

}


代码实现JWT客户端请求:
tocken校验拦截器:
package com.qianfeng.conf.interceptor;

import com.qianfeng.user.anno.Evade;
import com.qianfeng.user.util.CookieUtil;
import com.qianfeng.user.util.JWTTokenUtil;
import com.qianfeng.user.web.BaseController;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.SignatureException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.resource.ResourceHttpRequestHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;

/**
 * 拦截器,如果没有token,直接返回结果。
 * @author Martin
 */
public class TokenInterceptor implements HandlerInterceptor {


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

        if(handler instanceof ResourceHttpRequestHandler || handler instanceof AbstractErrorController){
            return true;
        }

        System.out.println("start");

        HandlerMethod handlerMethod = (HandlerMethod)handler;

        //如果需要规避
        if(hasEvade(handlerMethod)){
            System.out.println("evade : " +handlerMethod.toString());
            return true;
        }

        //从cookie中获取token
        String access_token = CookieUtil.getCookieValue(request, "access_token");

        //如果没有token,跳转到登录页面,这里需要判断一下是否是ajax请求
        if(StringUtils.isEmpty(access_token)){
            System.out.println("token is null");
            return vafail(request,response);
        }

        Claims claims = null;
        try{
            claims = JWTTokenUtil.phaseToken(access_token); //解析token
        }catch (ExpiredJwtException e){ //token已经过期
            System.out.println("token out time");
            return vafail(request,response);
        }catch (SignatureException e1){ //签名校验失败
            System.out.println("token signature fail");
            return vafail(request,response);
        }

        if(null != claims && !StringUtils.isEmpty(claims.get("uid").toString())){

            System.out.println("token success");

            //获取id,并将id设置到Controller

            if(handlerMethod.getBean() instanceof  BaseController){
                BaseController bean = (BaseController)handlerMethod.getBean();
                bean.setUserID(claims.get("uid").toString());
            }
            return true;
        }

        System.out.println("end");
        return false;
    }

    private boolean vafail(HttpServletRequest request,HttpServletResponse response) throws Exception{
        if(CookieUtil.isAjax(request)){
            response.getWriter().println("{'result':'6666','msg':'no token'}");
            return false;
        }
        response.sendRedirect("login");
        return false;
    }


    /**
     * 验证是否可以规避,不需要拦截
     * @param handlerMethod
     * @return
     */
    private boolean hasEvade(HandlerMethod handlerMethod){

        Method method = handlerMethod.getMethod();

        return method.getAnnotation(Evade.class) != null; //判断方法上面是否有自定义的规避注解。

    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // TODO
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // TODO
    }
}
LoginController登录实现代码:
package com.qianfeng.user.web;

import com.alibaba.dubbo.config.annotation.Reference;
import com.qianfeng.user.anno.Evade;
import com.qianfeng.user.dto.UserRequest;
import com.qianfeng.user.dto.UserResponse;
import com.qianfeng.user.IUserService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 登录
 * @author Martin
 */
@Controller
public class LoginController extends BaseController{

    @Reference //通过dubbo注入实现类
    IUserService userService;


    @GetMapping("/login")
    @Evade //自定义注解,配置该注解不拦截请求;
    public String jumpLogin(){
        return "login";
    }


    @RequestMapping(value = "/dologin")
    @Evade
    public String doLogin(UserRequest ur, HttpServletRequest request, HttpServletResponse response){

        UserRequest res = new UserRequest(ur.getUserName(),ur.getUserPass());

        if(null == userService){ //dubbo服务不可用
            return "error";
        }

        UserResponse rp = userService.login(res); //通过dubbo调用用户服务进行登录

        //登录失败跳转到登录页面
        if(null == response || StringUtils.isEmpty(rp.getUid())){
            return "login";
        }

        //按照以前,如果登录成功,是需要将用户信息保存到session中。
        request.getSession().setAttribute("user",rp);
        request.setAttribute("uid",rp.getUid());

        //使用token的方式,将信息保存到客户端  access_token : 注意,这里在获取的时候也需要这个值
        //response.addHeader("Set-Cookie","access_token="+rp.getToken()+";Path=/;HttpOnly"); //根目录的 cookie 设置token

        return "index";
    }

    //用来测试,是否可以直接访问资源
    @RequestMapping(value = "/buy")
    public String buy(HttpServletRequest request){

        //从Session中获取用户信息
        UserResponse ur = (UserResponse)request.getSession().getAttribute("user");
        System.out.println("Session中: /buy : id " + ur.getUid());

//        System.out.println("Token中:/buy : id " + getUserID());
//        request.setAttribute("uid",getUserID());

        return "info";
    }

}

Evade 自定义注解配置不需要验证的请求:
package com.qianfeng.user.anno;

import java.lang.annotation.*;

/**
 * 自定义注解,该注解可以设置不拦截处理
 * @author Martin
 */
@Documented
@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Evade {

}

  二: 基于CAS同公司不同域的单点登录解决方案

  Yelu大学研发的CAS单点登录原理:

  主要步骤分为:

  1:用户请求CAS Client的某一个网站,或者直接请求CAS Server登录页面;

  2:用户到CAS Server的登录页面进行验证,验证成功,CAS Server返回ticket到客户端;

  3:客户端再次请求CAS Client资源, CAS Client携带ticket到CAS Server认证,成功则继续访问;

  实现步骤:

  1: 搭建CAS Server端,可使用CAS的框架实现;可自定义登录页面;实现其内部接口即可;

  2: 搭建CAS Client端,就是公司不同的服务器系统,携带秘钥即可;

  三: 基于Auth2.0之第三方登录

  OAuth 2.0是用于**授权**的行业标准协议。

  OAuth 2.0 is the industry-standard protocol for **authorization**。

  OAuth定义了四个角色:

  资源所有者

  能够授予对受保护资源的访问权限的实体。

  当资源所有者是一个人时,它被称为

  最终用户。

  资源服务器

  托管受保护资源的服务器,能够接受

  并使用访问令牌响应受保护的资源请求。

  客户

  代表受保护的资源请求的应用程序

  资源所有者及其授权。“客户”一词的确如此

  并不意味着任何特定的实施特征(例如,

  应用程序是在服务器,桌面还是其他服务器上执行

  设备)。

  授权服务器

  服务器成功后向客户端发出访问令牌

  验证资源所有者并获得授权。

  #### 授权模式

  1 简化模式(Implicit)

  2 授权码模式(Authorization Code)

  3 密码模式(Resource Owner Password Credentials Grant)

  4 客户端模式(Client Credentials)

  场景: 公司的网站支持第三方登录(qq登录,微信登录,微博登录):

  qq开放平台:https://connect.qq.com/index.html

  微博开放平台: https://open.weibo.com/authentication/

  接入方式按照对应的平台操作即可,第三方网站接入文档提示非常全面.

  关于登录,还请题主需要关注一下 分布式下登录成功以后,分布式Session的问题以及解决方案;

  方案是用来解决问题的,这些方案没有好不好的说法,只有合不合适的说法,合适的才是最好的。

上一篇:HTML5工具初识之网页编辑器

下一篇:Mybatis 1对1关联 实现方式

QQ技术交流群

千锋Java开发官方①群
811099962

加入群聊