第4.1.4章 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);
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

warrah

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值