SSO单点登陆的具体实现

1,690 阅读6分钟

一、背景

上次做淘宝客项目用到了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配置代码

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:util="http://www.springframework.org/schema/util"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
       default-autowire="byName">


    <!-- 自定义Realm -->
    <bean id="upmsRealm" class="com.ruhnn.controller.shiro.UpmsRealm">
        <property name="credentialsMatcher" ref="credentialsMatcher"/>
        <property name="PermissionResolver" ref="urlPermissionResolver"/>
    </bean>
    <!-- 凭证匹配器 -->
    <bean id="credentialsMatcher" class="com.xxx.controller.shiro.CustomCredentialsMatcher"/>

    <bean id="urlPermissionResolver" class="com.xxx.controller.shiro.UrlPermissionResolver"/>

    <!-- 安全管理器 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="upmsRealm"/>
        <property name="sessionManager" ref="sessionManager"/>
    </bean>


    <!-- session管理器 -->
    <!--<bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">-->
    <bean id="sessionManager" class="com.ruhnn.controller.shiro.SsoSessionManager">
        <!-- 设置全局会话超时时间,默认30分钟(1800000) -->
        <property name="globalSessionTimeout" value="1800000"/>
        <!-- 是否在会话过期后会调用SessionDAO的delete方法删除会话 默认true -->
        <property name="deleteInvalidSessions" value="true"/>

        <!-- 会话验证器调度时间 -->
        <property name="sessionValidationInterval" value="1800000"/>
        <!-- session存储的实现 -->
        <property name="sessionDAO" ref="redisCacheSessionDAO"/>
        <property name="sessionIdCookie.name" value="SSO-SESSIONID"/>
    </bean>


    <!--</bean>-->
    <!-- 会话Session ID生成器 -->
    <bean id="sessionIdGenerator" class="com.xxx.controller.shiro.JavaUuidSessionIdGenerator"/>

    <bean id="redisCacheSessionDAO" class="com.xxx.controller.shiro.RedisCacheSessionDAO">
        <property name="sessionIdGenerator" ref="sessionIdGenerator"/>
    </bean>

    <bean id="tokenUserFilter" class="com.xxx.controller.shiro.TokenUserFilter"/>

    <!-- Shiro过滤器 -->
    <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
        <!-- Shiro的核心安全接口,这个属性是必须的 -->
        <property name="securityManager" ref="securityManager"/>
        <!-- 身份认证失败,则跳转到登录页面的配置 -->
        <property name="loginUrl" value="/index"/>
        <!-- 权限认证失败,则跳转到指定页面 -->
        <property name="unauthorizedUrl" value="/index"/>
        <!--登陆成功-->
        <property name="successUrl" value="/index"/>
        <property name="filters">
            <util:map>
                <entry key="token" value-ref="tokenUserFilter"></entry>
            </util:map>
        </property>
        <!-- Shiro连接约束配置,即过滤链的定义 -->
        <property name="filterChainDefinitions">
            <value>
                /login/** = anon
                /logout/** = logout
                /** = token
            </value>
        </property>
    </bean>

    <!-- 保证实现了Shiro内部lifecycle函数的bean执行 -->
    <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/>

    <!-- 开启Shiro注解 -->
    <bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"
          depends-on="lifecycleBeanPostProcessor"/>
    <bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
        <property name="securityManager" ref="securityManager"/>
    </bean>
</beans>

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<String> allPermissions = new HashSet<>();
        byte[] bytes = redisClient.get(key);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        if (bytes == null || bytes.length <= 0) {
            Set<FunctionDO> functionDOS = userService.queryUserFunction(userInfo.getInfo().getId(), userInfo.getWebId());
            if (CollectionUtils.isNotEmpty(functionDOS)) {
                Set<String> permissions = functionDOS.stream().map(FunctionDO::getUrl).collect(Collectors.toSet());
                allPermissions.addAll(permissions);
                redisClient.set(key, SerializeUtil.serialize(permissions));
            }
        } else {
            Set<String> 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<FunctionDO> publicLoginFunctionDOS = functionDao.queryPublicFunction(userInfo.getWebId());
            if (CollectionUtils.isNotEmpty(publicLoginFunctionDOS)) {
                Set<String> publicLoginPermissions = publicLoginFunctionDOS.stream().map(FunctionDO::getUrl).collect(Collectors.toSet());
                redisClient.set(ssoPublicLoginKey, SerializeUtil.serialize(publicLoginPermissions));
                allPermissions.addAll(publicLoginPermissions);
            }
        } else {
            Set<String> 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<Session> getActiveSessions() {

        Set<byte[]> keys = redisClient.keys(SsoConstants.REDIS_KEY);
        if (CollectionUtils.isEmpty(keys)) {
            return null;
        }
        Collection<Session> 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登陆系统

代码:git@github.com:civism/civism-sso.git