SSO单点登陆的具体实现

一、背景

上次做淘宝客项目用到了shiro,做了一个简单的单点登陆系统,同时,前后端分离是公司必走并且正在走的道路,所以公司也有想法做统一登陆。现在的情况是,公司处于半前后端分离状态,只是代码之间分开了,发布还是前端依赖后端容器,所以没有实现真正的前后端分离,公司想趁着这次完全改造完成,于是,sso项目启动了,后端开发设计就落到了我身上,同时找了个专业前端配合我。

二、项目中的要求

1、a系统登陆后,跳转到b系统不再登陆
2、a和b系统可能不在同一个域名下面
3、路由跳转完全交给前端,后端不做控制
4、支持分布式架构,当用户暴增的时候,能弹性扩展,实现分流
5、每个系统用户都有自己的角色、权限,必须保证没有权限不能访问数据
6、必须支持两种路径风格,比如restful风格和传统的请求风格
7、有些路径是不需要权限严重的,比如说开放接口。

三、技术选型

基于公司的要求,我还是选择了redis+shiro来做分布式支持和权限的验证,毕竟有了shiro经验并且shiro也是一个不错的安全框架。

四、登陆流程

1.登陆

用户a登陆 →请求该用户再该域名下的菜单权限→请求数据

2.跳转

已登陆用户a点击跳到b系统→请求该用户在该域名下的菜单权限→前端渲染菜单
已登陆a用户输入url跳转到b系统→cookie换取token→请求该用户在该域名下的菜单权限→前端渲染菜单
注意:
后端在做权限验证的时候token是必须的,所以在多个系统里面跳转,其实就是在做token的传递问题。

五、代码

1、spring配置代码




    
    
        
        
    
    
    

    

    
    
        
        
    


    
    
    
        
        
        
        

        
        
        
        
        
    


    
    
    

    
        
    

    

    
    
        
        
        
        
        
        
        
        
        
            
                
            
        
        
        
            
                /login/** = anon
                /logout/** = logout
                /** = token
            
        
    

    
    

    
    
    
        
    



2、登陆代码

/**
   * 认证信息,主要针对用户登录,
   */
  @Override
  protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
      SsoUserNameToken ssoUserNameToken = (SsoUserNameToken) authenticationToken;
      LoginEntity loginEntity = ssoUserNameToken.getLoginEntity();
      UserInfo userInfo = null;
      try {
          userInfo = userService.login(loginEntity);
          Serializable id = SecurityUtils.getSubject().getSession().getId();
          userInfo.setToken((String) id);
          redisClient.set((String) id, SerializeUtil.serialize(userInfo), LOGIN_EXPIRE);
      } catch (RuhnnException e) {
          if (e.getErrorCode().equals(ErrorType.USER_NO_EXIST)) {
              throw new UnknownAccountException();
          } else if (e.getErrorCode().equals(ErrorType.PASSWORD_ERROR)) {
              throw new IncorrectCredentialsException();
          } else if (e.getErrorCode().equals(ErrorType.TOKEN_INVALID)) {
              throw new ExpiredCredentialsException();
          }
      }
      if (loginEntity.getWay().intValue() == LoginWayEnum.Token_LOGIN.getWay().intValue()) {
          return new SimpleAuthenticationInfo(userInfo, userInfo.getToken(), getName());
      } else {
          return new SimpleAuthenticationInfo(userInfo, userInfo.getInfo().getPassword(), getName());
      }
  }

3、验证代码

/**
     * 授权
     *
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        UserInfo userInfo = (UserInfo) SecurityUtils.getSubject().getPrincipal();
        byte[] value = redisClient.get(userInfo.getToken());
        if (value != null) {
            userInfo = SerializeUtil.deserialize(value, UserInfo.class);
        }
        String key = SsoConstants.REDIS_ROLE_KEY + userInfo.getToken();//getSession().getId()
        Set allPermissions = new HashSet<>();
        byte[] bytes = redisClient.get(key);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        if (bytes == null || bytes.length <= 0) {
            Set functionDOS = userService.queryUserFunction(userInfo.getInfo().getId(), userInfo.getWebId());
            if (CollectionUtils.isNotEmpty(functionDOS)) {
                Set permissions = functionDOS.stream().map(FunctionDO::getUrl).collect(Collectors.toSet());
                allPermissions.addAll(permissions);
                redisClient.set(key, SerializeUtil.serialize(permissions));
            }
        } else {
            Set permissions = SerializeUtil.deserialize(bytes, Set.class);
            allPermissions.addAll(permissions);
        }
        String ssoPublicLoginKey = SsoConstants.REDIS_PUBLIC_LOGIN_KEY;
        byte[] ssoPublicLoginValue = redisClient.get(ssoPublicLoginKey);
        if (ssoPublicLoginValue == null) {
            List publicLoginFunctionDOS = functionDao.queryPublicFunction(userInfo.getWebId());
            if (CollectionUtils.isNotEmpty(publicLoginFunctionDOS)) {
                Set publicLoginPermissions = publicLoginFunctionDOS.stream().map(FunctionDO::getUrl).collect(Collectors.toSet());
                redisClient.set(ssoPublicLoginKey, SerializeUtil.serialize(publicLoginPermissions));
                allPermissions.addAll(publicLoginPermissions);
            }
        } else {
            Set publicLoginPermissions = SerializeUtil.deserialize(ssoPublicLoginValue, Set.class);
            allPermissions.addAll(publicLoginPermissions);
        }
        info.setStringPermissions(allPermissions);
        return info;
    }

4、支持分布式验证,重写sessionDAO

/**
 * @author star
 * @date 2018/5/22 下午3:49
 */
public class RedisCacheSessionDAO extends AbstractSessionDAO {

    @Resource
    private RedisClient redisClient;

    @Override
    protected Serializable doCreate(Session session) {

        Serializable sessionId = this.generateSessionId(session);
        this.assignSessionId(session, sessionId);
        redisClient.set(SsoConstants.REDIS_KEY + session.getId(), SerializeUtil.serialize(session), session.getTimeout() / 1000);
        return sessionId;
    }

    @Override
    protected Session doReadSession(Serializable serializable) {
        byte[] value = redisClient.get(SsoConstants.REDIS_KEY + serializable);
        return SerializeUtil.deserialize(value, Session.class);
    }

    @Override
    public void update(Session session) throws UnknownSessionException {
        if (session == null || session.getId() == null) {
            throw new NullPointerException("session is empty");
        }
        redisClient.set(SsoConstants.REDIS_KEY + session.getId(), SerializeUtil.serialize(session));

    }

    @Override
    public void delete(Session session) {
        if (session == null || session.getId() == null) {
            throw new NullPointerException("session is empty");
        }
        redisClient.remove(SsoConstants.REDIS_KEY + session.getId());
    }

    @Override
    public Collection getActiveSessions() {

        Set keys = redisClient.keys(SsoConstants.REDIS_KEY);
        if (CollectionUtils.isEmpty(keys)) {
            return null;
        }
        Collection collection = new HashSet<>();
        for (byte[] key : keys) {
            collection.add(SerializeUtil.deserialize(key, Session.class));
        }
        return collection;

    }
}

5、支持两种路径风格

public class SsoPathMatcher implements PatternMatcher {
    @Override
    public boolean matches(String p, String source) {
        //pattern数据库, source访问链接
        Pattern pattern = Pattern.compile(p);
        Matcher matcher = pattern.matcher(source);
        if (matcher.matches()) {
            return true;
        }
        return false;
    }
}
public class UrlPermission implements Permission {

    private static final Logger logger = LoggerFactory.getLogger(UrlPermission.class);

    private String url;

    public UrlPermission(String url){
        this.url = url;
    }

    @Override
    public boolean implies(Permission p) {
        if(! (p instanceof UrlPermission)){
            return false;
        }
        UrlPermission urlPermission = (UrlPermission) p;
        PatternMatcher patternMatcher = new RuhnnPathMatcher();
        logger.info("this.url(来自数据库中存放的通配符数据),在 Realm 的授权方法中注入的 => " + this.url);
        logger.info("urlPermission.url(来自浏览器正在访问的链接) => " +  urlPermission.url);
        System.out.println("this.url(来自数据库中存放的通配符数据),在 Realm 的授权方法中注入的 => " + this.url);
        System.out.println("urlPermission.url(来自浏览器正在访问的链接) => " +  urlPermission.url);
        boolean matches = patternMatcher.matches(this.url, urlPermission.url);
        return matches;
    }
}
public class UrlPermissionResolver implements PermissionResolver {
    @Override
    public Permission resolvePermission(String permissionString) {
        return new UrlPermission(permissionString);
    }
}

6、重写token的获取

public class SsoSessionManager extends DefaultWebSessionManager {


    @Override
    protected Serializable getSessionId(ServletRequest httpRequest, ServletResponse response) {
        HttpServletRequest request = (HttpServletRequest) httpRequest;
        return request.getHeader("token");
    }
}

7、需要用到sso的项目过滤器

public class SsoFilter implements Filter {


    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        PrintWriter out = null;
        out = response.getWriter();
        String token = request.getHeader("token");
        if (StringUtils.isEmpty(token)) {
            token = request.getParameter("token");
        }
        if (StringUtils.isEmpty(token)) {
            out.write(JSON.toJSONString(new SsoResponse(ErrorType.INVALID_ARGUMENT)));
            return;
        }
        String uri = request.getRequestURI();
        JSONObject result = JSON.parseObject(HttpUtils.get("localhost:9999" + uri, token));
        if (result.getString("success").equals("true")) {
            filterChain.doFilter(servletRequest, servletResponse);
        } else {
            out.write(result.toJSONString());
            return;
        }
    }

    @Override
    public void destroy() {

    }

}

六、总结

后端逻辑很简单,很多事情都放在了前端处理,后端只有2个要求,一个是token值一个是域名值,token 能判断用户权限,域名能判断该用户第一次访问的时候的菜单权限。所以在前端跟很短对接的时候 主要问题是token获取不到,因为跨域穿值问题,所以才有了用cookie换取token的接口,并且controller层也有了改动,由于前端需要跨域请求,所以用了jsonp,在获取成功后,controller 返回的其实是一段jsonp可执行的代码。以上就是我设计的sso登陆系统。目前还在测试阶段,但是逻辑是通的,有兴趣的朋友可以加我QQ:695234456
代码:[email protected]:civism/civism-sso.git
一起装逼一起飞

你可能感兴趣的:(SSO单点登陆的具体实现)