第4.1.2章 WEB系统最佳实践 单点登录

我负责的平台,单点登录经历过两个阶段,第一个阶段是开源CAS系统,第二个阶段根据CAS原理自己实现的。
1 开源CAS系统
可以通过使用 CAS 在 Tomcat 中实现单点登录来了解CAS的一些概况,
1
关于 CAS—认证原理,这里说的也比较清楚明白
关于shiro与cas集成,可以参考Apache shiro(3)—cas + shiro配置说明
1.1 页面是如何跳转到sso系统

<bean id="casRealm" class="com.xxxx.framework.user.shiro.MyCasRealm">
        <!-- 关闭认证和授权缓存 -->
        <property name="cachingEnabled" value="true"/>
        <property name="authenticationCachingEnabled" value="true"/>
        <property name="authenticationCacheName" value="authenticationCache"/>
        <property name="authorizationCachingEnabled" value="true"/>
        <property name="authorizationCacheName" value="authorizationCache"/>
        <!-- CAS Server -->
        <property name="casServerUrlPrefix" value="${cas.server.intranet}"/>
        <!-- 客户端的回调地址设置,必须和下面的shiro-cas过滤器拦截的地址一致 -->
        <property name="casService" value="${cas.client}/login"/>
    </bean>
<!-- 安全管理器 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="casRealm"/>
        <!-- <property name="sessionManager" ref="sessionManager"/> -->
        <property name="cacheManager" ref="cacheManager"/>
        <property name="rememberMeManager" ref="rememberMeManager"/>
        <!-- sessionMode参数设置为native时,那么shrio就将用户的基本认证信息保存到缺省名称为shiro-activeSessionCache 的Cache中 -->
        <!--<property name="sessionMode" value="native" />-->
        <property name="subjectFactory" ref="casSubjectFactory"/>
    </bean>
    <bean id="userFilter" class="com.xxxx.framework.shiro.filter.UserFilter" />

    <!-- <bean id="casFilter" class="org.apache.shiro.cas.CasFilter"> -->
    <bean id="casFilter" class="com.xxxx.framework.user.shiro.MyCasFilter">
        <!-- 配置验证错误时的失败页面  -->
        <property name="failureUrl" value="${cas.client}"/>
    </bean>
    <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
        <property name="securityManager" ref="securityManager" />
        <!-- 设定角色的登录链接,这里为cas登录页面的链接可配置回调地址  -->
        <property name="loginUrl" value="${cas.server}?service=${cas.client}/login" />
        <property name="successUrl" value="/index" />
        <property name="unauthorizedUrl" value="/"/> 
        <property name="filters">
            <util:map>
                <entry key="user" value-ref="userFilter"/>
                <!-- 添加casFilter到shiroFilter -->
                <entry key="cas" value-ref="casFilter"/>
            </util:map>
        </property>
        <property name="filterChainDefinitions">
            <value>
            /resources/** = anon
            /logout = anon
            /login = cas
            /** = user
            </value>
        </property>
    </bean>

自定义的UserFilter如下,主要针对ajax做的扩展,因为我们的管理页面采用的是iframe方式,所以需要特殊处理,返回状态码,这样session超时,可以通过js来将页面跳转到登录页面,而不是在iframe中显示登录页面。体验不好

public class UserFilter extends org.apache.shiro.web.filter.authc.UserFilter {

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        HttpServletRequest req = WebUtils.toHttp(request);
        if (HttpUtil.isAjax(req)){
            HttpServletResponse res = WebUtils.toHttp(response);  
            res.sendError(HttpServletResponse.SC_UNAUTHORIZED); 
        } else if (HttpUtil.isPost(req)){
            HttpServletResponse res = WebUtils.toHttp(response);  
            res.sendError(HttpServletResponse.SC_UNAUTHORIZED); 
        } else {
            saveRequestAndRedirectToLogin(request, response);
        }

        return false;
    }
}

anon是被忽略的,这里讲一下页面是怎么跳转到sso的登录页面
在浏览器中输入http://192.168.4.187:8097/pssorg.apache.shiro.web.filter.authc.UserFilter
1
显示访问被拒绝,在org.apache.shiro.web.filter.AccessControlFilter中可以看到
2
于是页面跳转到sso的页面
3

2 自研SSO系统
cas系统太过复杂,可能他考虑的方面比较多,因此我们可以结合它的源码,改造成自己的轻量级SSO。
2.1 sso登录时发生了什么
service中记录了是从哪个CAS Client 过来的,将service存储到登录页面的隐藏域中。

@RequestMapping(value = "", method = RequestMethod.GET)
    public String welcome(String service, HttpServletRequest request, Model model) {
        if (CheckEmptyUtil.isEmpty(service)) {
            return "redirect:login";
        } else {
            return "redirect:login?service=" + service;
        }
    }

如果没有登录,则跳转到sso的登录页面

@RequestMapping(value = "login", method = RequestMethod.GET)
    public String login(String service, HttpServletRequest request, Model model) {
        // 1、检查Subject,未登录进入登录页面,已登录则继续下一步
        Subject subject = SecurityUtils.getSubject();
        if (!subject.isAuthenticated()) {
            if (!CheckEmptyUtil.isEmpty(service)) {
                model.addAttribute("service", service);
            }
            return "login";
        }

        // 2、检查是否有service参数,否则进入门户首页,是则生成ST,跳转service对应的地址(跨域)
        AppService appService = AppService.createServiceFrom(service);
        if (appService == null) {
            model.addAttribute("systems", getSystems());
            return "system/index";
        }

        // 如果该service已经存在,替换原来的Service
        Session session = SessionUtil.getCurrentSession();
        // 生成新的ST
        String stId = centralAuthenticationService.grantServiceTicket(session, appService);

        // 跳转service对应的地址(跨域)
        return "redirect:" + appService.getRedirectUrl(stId);
    }

输入用户名、密码后,用户登录下面核心代码在currentUser.login(token);,login之后会进入UserRealm中doGetAuthenticationInfo方法,对用户登录进行身份认证

@RequestMapping(value = "doLogin", method = RequestMethod.POST)
    @ResponseBody
    public ResponseResult<String> doLogin(String username, String password, String captcha, String service,
            HttpServletRequest request) {
        // 传递service到前台
        ResponseResult<String> response = new ResponseResult<String>(true);
        response.setData(service);

        Subject currentUser = SecurityUtils.getSubject();
        if (!currentUser.isAuthenticated()) {
            UsernamePasswordCaptchaToken token = new UsernamePasswordCaptchaToken(username, password, false,
                    IpUtil.getIpAddr(request), captcha);
            try {
                currentUser.login(token);
            } catch (UnknownAccountException e) {
                response.setSuccess(false);
                response.setMsg("帐号或密码错误,请重试");
            } catch (IncorrectCredentialsException e) {
                response.setSuccess(false);
                response.setMsg("帐号或密码错误,请重试");
            } catch (DisabledAccountException e) {
                response.setSuccess(false);
                response.setMsg("帐号被禁用,请联系管理员");
            } catch (FirstLoginException e) {
                response.setSuccess(false);
                response.setMsg("请修改初始密码");
                response.setData(FirstLoginException.class.getSimpleName());
                // } catch (ExcessiveAttemptsException e) {
                // response.setSuccess(false);
                // response.setMsg("密码错误次数过多,请15分钟后重试");
            } catch (DubboException e) {
                response.setSuccess(false);
                response.setMsg("用户中心连接失败,请联系管理员");
            } catch (AuthenticationException e) {
                response.setSuccess(false);
                response.setMsg(e.getMessage());
            }
        }
        return response;
    }
@Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordCaptchaToken myToken = (UsernamePasswordCaptchaToken) token;
        String username = myToken.getUsername();
        String password = new String(myToken.getPassword());// 密码md5

        // 验证码校验
        if (is_produce) {
            String captcha = (String) getSession().getAttribute(com.google.code.kaptcha.Constants.KAPTCHA_SESSION_KEY);
            if (!captcha.equalsIgnoreCase(myToken.getCaptcha())) {
                throw new CaptchaException();
            }
        }

        UcsUserDto user = null;
        List<UcsSystemPermission> permissions = null;
        UcsEnterprise ent = null;
        UcsOrganization org = null;
        try {
             user = userService.login(username, password);
        } catch (UsernameNotExistException e) {
            logger.debug(e.getMessage());
            throw new UnknownAccountException();
        } catch (PasswordWrongException e) {
            logger.debug(e.getMessage());
            throw new IncorrectCredentialsException();
        } catch (UserDeniedException e) {
            logger.debug(e.getMessage());
            throw new DisabledAccountException();
        } catch (UnchangedPasswordException e) {
            logger.debug(e.getMessage());
            throw new FirstLoginException();
        } catch (Exception e) {
            logger.error("用户中心连接失败,请联系管理员", e);
            throw new DubboException("用户中心连接失败,请联系管理员");
        }

        try {
            permissions = userService.getPermissionByUserId(domainId, user.getId());
            ent = userService.getEnterpriseById(user.getEntId());
            org = userService.getOrganizationById(user.getOrgId());
        } catch (Exception e) {
            logger.error("用户中心连接失败,请联系管理员", e);
            throw new DubboException("用户中心连接失败,请联系管理员");
        }
        // if (permissions == null) {
        // logger.error("该用户无本系统权限");
        // throw new AuthenticationException("该用户无本系统权限");
        // }
        // 给菜单排序
        List<Menu> menus = null;
        if (!CheckEmptyUtil.isEmpty(permissions)) {
            menus = convertToMenu(permissions, domain);
            Collections.sort(menus);
        }
        ShiroUser shiroUser = new ShiroUser(user.getId(), user.getUsername(), user.getName(), menus,
                convertToEnterprise(ent), convertToOrganization(org));
        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(shiroUser, password, getName());
        return authenticationInfo;
    }

身份认证成功后,将会再次进入,这次将生ST,待着个ST进行页面跳转
http://192.168.4.187:8097/pss/login?ticket=ST-0-lmVPqCIQnxqqIBulirEaA3tK4ddVXKkUSMU

@RequestMapping(value = "login", method = RequestMethod.GET)
    public String login(String service, HttpServletRequest request, Model model) {
        // 1、检查Subject,未登录进入登录页面,已登录则继续下一步
        Subject subject = SecurityUtils.getSubject();
        if (!subject.isAuthenticated()) {
            if (!CheckEmptyUtil.isEmpty(service)) {
                model.addAttribute("service", service);
            }
            return "login";
        }

        // 2、检查是否有service参数,否则进入门户首页,是则生成ST,跳转service对应的地址(跨域)
        AppService appService = AppService.createServiceFrom(service);
        if (appService == null) {
            model.addAttribute("systems", getSystems());
            return "system/index";
        }

        // 如果该service已经存在,替换原来的Service
        Session session = SessionUtil.getCurrentSession();
        // 生成新的ST
        String stId = centralAuthenticationService.grantServiceTicket(session, appService);

        // 跳转service对应的地址(跨域)
        return "redirect:" + appService.getRedirectUrl(stId);
    }

2.2 st生成

@Override
    public String grantServiceTicket(final Session session, final AppService service) {
        // 生成stId
        final String generatedServiceTicketId = uniqueTicketIdGenerator.getNewTicketId(ServiceTicket.PREFIX);
        logger.info("Generated service ticket id [{}] for session [{}]", generatedServiceTicketId,
                session.getId().toString());
        //生成ST
        final ServiceTicket serviceTicket = new ServiceTicketImpl(generatedServiceTicketId, session.getId().toString(), service, true);

        //更新TGT
        TicketGrantTicket tgt = SessionUtil.getTgt(session);
        if(tgt == null) {
            tgt = new TicketGrantTicket();
            tgt.setUsername(UserUtil.getCurrentShiroUser().getLoginName());
        }
        tgt.putService(generatedServiceTicketId, service);
        SessionUtil.setTgt(session, tgt);
        serviceTicketRegistry.addTicket(serviceTicket);

        return serviceTicket.getId();
    }

我将st放入到redis中,采用redis的过期策略,设置过期时间为10ms

 public void addTicket(final Ticket ticket) {
        Assert.notNull(ticket, "ticket cannot be null");

        logger.debug("Added ticket [{}] to registry.", ticket.getId());
        redisService.putString(ticket.getId(), ticket, timeToKillInMilliSeconds);
    }

2.3 cas client验证ST
2
在CasRealm中可自行doGetAuthenticationInfo方法,其中Assertion casAssertion = ticketValidator.validate(ticket, getCasService());这段代码校验service ticket是否合法

@Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        CasToken casToken = (CasToken) token;
        if (token == null) {
            return null;
        }

        String ticket = (String) casToken.getCredentials();
        if (!StringUtils.hasText(ticket)) {
            return null;
        }

        TicketValidator ticketValidator = ensureTicketValidator();

        try {
            // contact CAS server to validate service ticket
            Assertion casAssertion = ticketValidator.validate(ticket, getCasService());
            // get principal, user id and attributes
            AttributePrincipal casPrincipal = casAssertion.getPrincipal();
            String username = casPrincipal.getName();
            logger.debug("Validate ticket : {} in CAS server : {} to retrieve user : {}",
                    new Object[] { ticket, getCasServerUrlPrefix(), username });

            Map<String, Object> attributes = casPrincipal.getAttributes();
            // refresh authentication token (user id + remember me)
            casToken.setUserId(username);
            String rememberMeAttributeName = getRememberMeAttributeName();
            String rememberMeStringValue = (String) attributes.get(rememberMeAttributeName);
            boolean isRemembered = rememberMeStringValue != null && Boolean.parseBoolean(rememberMeStringValue);
            if (isRemembered) {
                casToken.setRememberMe(true);
            }
            // create simple authentication info
            // 从用户中心获取用户信息,产生shiroUser

            UcsUserDto user = null;
            List<UcsSystemPermission> permissions = null;
            UcsEnterprise ent = null;
            UcsOrganization org = null;
            try {
                user = userService.getUserByUsername(username);
                permissions = userService.getPermissionByUserId(domainId, user.getId());
                ent = userService.getEnterpriseById(user.getEntId());
                org = userService.getOrganizationById(user.getOrgId());
            } catch (Exception e) {
                logger.error(e.getMessage());
                // throw new UcsException();
            }
            if (user == null || permissions == null) {
                logger.error("从用户中心获取数据失败");
                throw new UnknownAccountException();
            }
            // 给菜单排序
            List<Menu> menus = convertToMenu(permissions, domain);
            if (!CheckEmptyUtil.isEmpty(menus)) {
                Collections.sort(menus);
            }
            ShiroUser shiroUser = new ShiroUser(user.getId(), user.getUsername(), user.getName(), menus,
                    convertToEnterprise(ent), convertToOrganization(org));

            return new SimpleAuthenticationInfo(shiroUser, ticket, getName());
        } catch (TicketValidationException e) {
            logger.error(e.getMessage(), e);
            throw new CasAuthenticationException("Unable to validate ticket [" + ticket + "]", e);
        }

查看org.jasig.cas.client.util.CommonUtils中,可以看到
cas client会向http://192.168.4.187:8099/gateway/serviceValidate?ticket=ST-8-A0evT9M9DU69tdZqJ2gBedn2d7lZ3gmXiHK&service=http%3A%2F%2F192.168.4.187%3A8097%2Fpss%2Flogin
如果返回下面的,则表示ticket验证失败

<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'><cas:authenticationFailure code='INVALID_TICKET'>INVALID_TICKET_SPEC</cas:authenticationFailure></cas:serviceResponse>

ticket验证成功则是

<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'><cas:authenticationSuccess><cas:user>admin</cas:user></cas:authenticationSuccess></cas:serviceResponse>
  public static String getResponseFromServer(final URL constructedUrl, final HostnameVerifier hostnameVerifier, final String encoding) {
        URLConnection conn = null;
        try {
            conn = constructedUrl.openConnection();
            if (conn instanceof HttpsURLConnection) {
                ((HttpsURLConnection)conn).setHostnameVerifier(hostnameVerifier);
            }
            final BufferedReader in;

            if (CommonUtils.isEmpty(encoding)) {
                in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            } else {
                in = new BufferedReader(new InputStreamReader(conn.getInputStream(), encoding));
            }

            String line;
            final StringBuilder stringBuffer = new StringBuilder(255);

            while ((line = in.readLine()) != null) {
                stringBuffer.append(line);
                stringBuffer.append("\n");
            }
            return stringBuffer.toString();
        } catch (final Exception e) {
            LOG.error(e.getMessage(), e);
            throw new RuntimeException(e);
        } finally {
            if (conn != null && conn instanceof HttpURLConnection) {
                ((HttpURLConnection)conn).disconnect();
            }
        }

    }

这样看来真正验证ticket是在cas server端,从cas原来中到对应的controller,仿照做一下
3

@RequestMapping(value = "/serviceValidate", method = RequestMethod.GET)
    @ResponseBody
    public String index(String ticket, String service, HttpServletRequest request, Model model) {
        AppService appService = AppService.createServiceFrom(service);
        if (appService == null || ticket == null) {
            logger.debug("Could not identify service and/or service ticket. Service: {}, Service ticket id: {}", service, ticket);
            return generateErrorView("INVALID_REQUEST", "INVALID_REQUEST");
        }
        try {
            String username = centralAuthenticationService.validateServiceTicket(ticket, service);
            logger.debug("Successfully validated service ticket {} for service [{}]", ticket, appService.getId());
            return generateSuccessView(username);
        } catch (Exception e) {
            logger.debug("Service ticket [{}] does not satisfy validation specification.", ticket);
            return generateErrorView("INVALID_TICKET", "INVALID_TICKET_SPEC");
        }
    }

如何校验st呢,

@Override
    public String validateServiceTicket(String serviceTicketId, String service) throws TicketException {
        //根据ST的id获取ST
        final ServiceTicket serviceTicket = (ServiceTicket) serviceTicketRegistry.getTicket(serviceTicketId, ServiceTicketImpl.class);
        //校验数据,可能不存在或过期
        if (serviceTicket == null) {
            logger.info("ServiceTicket [{}] does not exist or expired.", serviceTicketId);
            throw new InvalidTicketException(serviceTicketId);
        }

        //获取用户登录时的Session
        final String sessionId = serviceTicket.getSessionId();
        Session session = null;
        try {
            session = sessionDAO.readSession(sessionId);
        } catch (Exception e) {
            logger.error("Session [{}] does not exist or expired", session.getId().toString());
            throw new TicketValidationException(serviceTicket.getService());
        }
        TicketGrantTicket tgt = SessionUtil.getTgt(session);

        if (!tgt.getServices().containsKey(serviceTicketId)) {
            logger.info("ServiceTicket [{}] does not exist.", serviceTicketId);
            throw new InvalidTicketException(serviceTicketId);
        }

        //获取Service
        AppService appService = tgt.getServices().get(serviceTicketId);
        synchronized (serviceTicket) {
            //检查service是否和校验请求的service匹配
            if (!serviceTicket.isValidFor(appService)) {
                logger.error("ServiceTicket [{}] with service [{}] does not match supplied service [{}]",
                        serviceTicketId, serviceTicket.getService().getId(), service);
                throw new TicketValidationException(serviceTicket.getService());
            }
        }
        //成功使用后删除ticket
        serviceTicketRegistry.deleteTicket(serviceTicketId);
        return tgt.getUsername();
    }

2.3 TGT产生与更新
debug跟踪后,TGT中service对象,将st作为key,应用url信息作为service对象,保存到session中。

1
下面是tgt的内部结构

public class TicketGrantTicket implements Serializable {

    /**
     * 
     */
    private static final long serialVersionUID = -2526554734322014693L;

    private String username;

    //stid:service
    private Map<String, AppService> services;

    //serviceId,stId
    private Map<String, String> serviceIds;

    public TicketGrantTicket() {
        services = new HashMap<String, AppService>();
        serviceIds = new HashMap<String, String>();
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Map<String, AppService> getServices() {
        return services;
    }

    public void setServices(Map<String, AppService> services) {
        this.services = services;
    }

    public void putService(String stId, AppService service) {
        //检查当前service是否登录过
        if(serviceIds.containsKey(service.getId())) {
            //曾经登录过则删除原AppService
            String oldStId = serviceIds.get(service.getId());
            services.remove(oldStId);
        } else {
            //未登陆过则保存
            serviceIds.put(service.getId(), stId);
        }
        services.put(stId, service);
    }
}

下面这个图,讲述的是输入cas client地址后,会被重定向到cas server, 由cas servicer完成登录后,将登录地址带ticket重定向到cas client,cas client再向cas server发送请求验证ticket是否有效,有效则根据username获取权限。
2
下面截图可以到service里有什么
3
2.4 先登录cas server,再进入cas client的流程是怎样
自研cas server,是想定制统一门户页面,故需要先进入cas server,然后从cas server的快捷入口,进入到cas client中,这个流程入下图所示,
4
从cas server重定向到http://192.168.4.187:8097/pss/login?ticket=ST-15-XCJbhQTd7xYbcSAxfGcYumedfmsQK4j4T2s做了什么呢?
查看org.apache.shiro.cas.CasFilter,他将ticket放入token中

protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String ticket = httpRequest.getParameter(TICKET_PARAMETER);
        return new CasToken(ticket);
    }

猜你喜欢

转载自blog.csdn.net/warrah/article/details/79897359
今日推荐