1. 单点登录是什么?
单点登录全称Single Sign On(以下简称SSO),是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。
2. 单点登录的原理
相比于单系统登录,sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。
3. 单点登录的实现
由于手机端不能存cookie,所以传统的session存储登录信息的登录方式(后面简称session登录)不能用,所以需要一个既支持session登录后访问有访问权限控制的url,又支持无状态化token方式的认证。
对于无状态话的token认证,目前比较流行的是JWT token。
由于我们使用的Shiro认证授权框架,Shiro默认实现的是基于Session的认证和授权,为了实现同时支持Session和JWT Token两种认证方式,需要在了解Shiro认证授权框架的集成上实现JWT token的访问控制逻辑。
3.1. 认证流程
针对用户需求和安全需求,需要实现以下几种场景的认证。
- 基于浏览器的Session认证方式,需要实现多个web应用之间的SSO。
- 移动端基于JWT Token的无状态认证,需要考虑token的足够安全和token的自动刷新(因为移动端不能因为token的过期,而中断应用导致用户体验差)
- 由前端发起,后端微服务之间的调用,由于这种调用关系,微服务之间会进行session的共享,可以通过cookie来实现SSO
- 来自于内部的一些服务,比如定时的Point service,由于它无Session,因此对于这种服务,系统会内置一个系统用户,再以JWT Token的方式进行认证。
上面红色连接线表示基于JWT Token的Mobile App认证方式,蓝色连线表示基于Session的登录方式。其中内部定时器或者服务也是基于JWT Token认证方式,只是需要内置一些系统用户。
3.2. 实现步骤
3.2.1. Shiro默认访问步骤
场景一、访问登录请求
比如我们常见会定义一个/login的请求,接受用户名和密码参数(一般密码都会加盐hash)。对于这种请求,Shiro会执行以下的两步逻辑。
- 在代码里会写到获取Shiro的Subject,创建一个token,通常是UsernamePasswordToken,将请求参数的账户密码填充进去,然后调用subject.login(token)
- 接下来到支持处理这个token的realm中调用 realm doGetAuthenticationInfo 鉴权,鉴权后,session中就存有你的登录信息了
场景二、访问普通API
- 到 Shiro 的 PathMatchingFilter preHandle 方法判断一个请求的访问权限是可以直接放行还是需要 Shiro 自己实现的AccessControlFilter 来处理访问请求
- 假设到了 AccessControlFilter 实现类,首先在 isAccessAllowed 判断是否可以访问,如果可以则直接放行访问,如果不可以则到 onAccessDenied 方法处理,并继续调用 realm doGetAuthorizationInfo 授权判断是否有足够的权限来访问
- 假设有足够的权限的话就访问到自己定义的 controller了
3.2.2. 支持JWT Token访问
Shiro默认支持的是Session认证方式,为了支持JWT Token认证方式,需要实现 AccessControlFilter 来修改控制访问的逻辑。需要完成的工作有以下方面:
- [自定义实现AccessControlFilter (JWTAuthcFilter)]
- [Shiro的过滤链上添加自定义的]
- [自定义realm(JWTShiroRealm][),不用账户密码登录鉴权(UsernamePasswordToken),而使用自定义的token(JWTToken]
- [自定义一个token(TokenRealm),存储参数和加密参数等]
- 增加一个JWTTokenRefreshInterceptor来拦截请求,检测是否需要刷新token
3.2.3. 实现详情
具体见代码,分别是JWTAuthcFilter,JWTPrincipal,JWTTokenRefreshInterceptor,JWTWebMvcConfigurer,ShiroConfig,JWTToken等。
- JWTAuthcFilter
import com.google.common.base.Strings;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.subject.Subject;import org.apache.shiro.web.filter.AccessControlFilter;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j@AllArgsConstructor
public class JWTAuthcFilter extends AccessControlFilter {
private final String headerKeyOfToken;
private final JWTUserAuthService userAuthService;
private final boolean isDisabled;
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if(isDisabled){
log.info("Shiro Authentication is disabled, hence can access api directly.");
return true;
}else{
log.info("Shiro Authentication is enabled, to continue to execute onAccessDenied method");
}
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
// 登录状态判断
log.info("onAccessDenied......");
Subject subject = getSubject(request, response);
if (subject.isAuthenticated()) {
return true;
}
//从header或URL参数中查找token
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader(headerKeyOfToken);
if (Strings.isNullOrEmpty(authorization)) {
authorization = req.getParameter(headerKeyOfToken);
}
JWTToken token = new JWTToken(authorization);
try {
getSubject(request, response).login(token);
} catch (Exception e) {
log.error("认证失败:" + e.getMessage());
this.userAuthService.onAuthenticationFailed((HttpServletRequest) request, (HttpServletResponse) response);
return false;
}
return true;
}}
- JWTPrincipal
import lombok.Data;
@Datapublic class JWTPrincipal {
private String account;
private int userId;
private long expiresAt;
}
- JWTWebMvcConfigurer
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Slf4j@Configura