我负责的平台,单点登录经历过两个阶段,第一个阶段是开源CAS系统,第二个阶段根据CAS原理自己实现的。
1 开源CAS系统
可以通过使用 CAS 在 Tomcat 中实现单点登录来了解CAS的一些概况,
关于 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/pss
,org.apache.shiro.web.filter.authc.UserFilter
中
显示访问被拒绝,在org.apache.shiro.web.filter.AccessControlFilter
中可以看到
于是页面跳转到sso的页面
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
在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,仿照做一下
@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中。
下面是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获取权限。
下面截图可以到service里有什么
2.4 先登录cas server,再进入cas client的流程是怎样
自研cas server,是想定制统一门户页面,故需要先进入cas server,然后从cas server的快捷入口,进入到cas client中,这个流程入下图所示,
从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);
}