解析主流的SpringSecurity安全框架,结合若依框架进行分析。
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
个人理解就是一个用户请求过来,过滤器就像漏斗一样进行层层筛选,直到检验用户请求合法则放通否则失败。
若依项目关键类:
SpringSecurity源码关键类:
UsernamePasswordAuthenticationFilter:实际并未执行
ProviderManager:AuthenticationManager的实现类,遍历多个provider,获取到符合要求的provider。在扩展章节中得到体现
AbstractUserDetailsAuthenticationProvider:实现了AuthenticationProvider接口的用户认证实现抽象类(默认实现)
authenticate()认证方法
supports()支持某类认证的校验
UsernamePasswordAuthenticationToken:认证对象(默认实现)
查看com.ruoyi.framework.web.service.SysLoginService的login()方法,大体流程:
观察SecurityConfig,我们能够发现以下过滤器
// 查看配置类
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
// ...
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
}
}
实际上主要的过滤器还有:
org.springframework.security.web.context.SecurityContextPersistenceFilter
首当其冲的一个过滤器,非常重要 主要是使用SecurityContextRepository在session中保存或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文,SecurityContext中存储了当前用户的认证和权限信息。
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
此过滤器用于继承SecurityContext到Spring异步执行机制中的WebAsyncManager,和spring整合必须的。
org.springframework.security.web.header.HeaderWriterFilter
向请求的header中添加响应的信息,可以在http标签内部使用security:headers来控制
org.springframework.security.web.csrf.CsrfFilter
Csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果不包含则报错,起到防止csrf攻击的效果
org.springframework.security.web.authentication.logout.LogoutFilter
匹配URL为/logout的请求,实现用户退出,清除认证信息
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter(并未加入到过滤器链路中)
认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认的认证界面
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
由此过滤器生成一个默认的退出登录页面
org.springframework.security.web.authentication.www.BasicAuthenticationFilter
此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头部信息
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
通过HttpSessionRequestCache内部维护一个RequestCache,用于缓存HttpServletRequest
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
针对ServletRequest进行一次包装,使得request具有更加丰富的API
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存储到SecurityContextHolder中,SpringSecurity为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份
org.springframework.security.web.session.SessionManagementFilter
SecurityContextRepository限制同一个用户开启多个会话的数量
org.springframework.security.web.access.ExceptionTranslationFilter
异常转换过滤器位于整个SpringSecurityFilterChain的后方,用来转换整个链路中出现的异常
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
获取所有配置资源的访问授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。
分析默认实现,创建认证对象UsernamePasswordAuthenticationToken,将认证对象丢入到authenticationManager中执行authenticate()方法的原理。
类图关系如下,遍历ProviderManager中的providers list集合获取AuthenticationProvider接口集合中符合要求的provider:
通过ProviderManager能够找到执行AbstractUserDetailsAuthenticationProvider的authenticate()方法(可通过supports发现)
AbstractUserDetailsAuthenticationProvider实现了UsernamePasswordAuthenticationToken的support配置,对应ProviderManager里的遍历查找
AbstractUserDetailsAuthenticationProvider的authenticate抽象类中主要是retrieveUser接口方法->由DaoAuthenticationProvider继承AbstractUserDetailsAuthenticationProvider进行具体实现
此处观察SecurityConfig,能够查看到实现了UserDetailsService接口的自定义实现类UserDetailsServiceImpl被设置,所以实际调用的则是自定义UserDetailsServiceImpl的loadUserByUsername()方法,就可以写结合实际业务逻辑的获取用户信息过程
执行loadUserByUsername()成功后返回认证对象
终于可以回到最外层SysLoginService
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
String token = IdUtils.fastUUID();
loginUser.setToken(token);
使用性能更好的ThreadLocalRandom生成UUID,并设置loginUser对象中的token属性。
setUserAgent(loginUser);
//setUserAgent...
loginUser.setIpaddr(ip);
loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
loginUser.setBrowser(userAgent.getBrowser().getName());
loginUser.setOs(userAgent.getOperatingSystem().getName());
设置登录用户的ip,登录地点,登录客户端等信息
refreshToken(loginUser);
//refreshToken...
loginUser.setLoginTime(System.currentTimeMillis());
// 默认过期时间为半个小时
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存 格式为login_tokens:token作为缓存key
String userKey = getTokenKey(loginUser.getToken());
// 设置缓存 key是login_tokens:token value是loginUser对象
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
设置登录时间,设置过期时间,设置缓存信息
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
//createToken...
String token = Jwts.builder()
// 关联上面生成的uuid
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
将上面生成的uuid与该Jwt进行关联,并使用Jwt内置的方法生成一个token并返回给前端,至此token生成结束。
JwtAuthenticationTokenFilter:
优先从请求头里获取token,如果没有则从cookie中获取token;
// getLoginUser...
// 获取请求携带的令牌
String token = getToken(request);
if (token == null) {
token = getCookieToken(request);
}
if (StringUtils.isNotEmpty(token))
{
try
{
Claims claims = parseToken(token);
// 解析对应的权限以及用户信息
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
// 获取uuid
String userKey = getTokenKey(uuid);
// 从缓存中根据uuid的key获取对应的登录用户信息
LoginUser user = redisCache.getCacheObject(userKey);
return user;
}
catch (Exception e)
{
}
}
return null;
// verifyToken...
/**
* 验证令牌有效期,相差不足20分钟,自动刷新缓存
*
* @param loginUser
* @return 令牌
*/
public void verifyToken(LoginUser loginUser)
{
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
{
// 刷新token过期时间
refreshToken(loginUser);
}
}
根据上述的源码分析,我们可以知道AbstractAuthenticationToken,AuthenticationProvider这两个是最重要的配套设施,前者是认证对象,后者是具体认证实现。所以我们按照默认实现来自定义独特的认证方法,比如微信登录,扫码登录等等。举例:
新建一个继承AbstractAuthenticationToken的Token认证对象
public class XXXAuthenticationToken extends AbstractAuthenticationToken {
/**
* 用户实际信息对象
*/
private final Object principal;
/**
* 用户凭证
*/
private Object credentials;
/**
* 自定义所需的其他属性
*/
private String userName;
private String id;
private String targetUrl;
}
新建一个实现AuthenticationProvider接口的自定义Provider类,实现authenticate()和support()方法
@Component
public class XXXAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 关键实现...例如使用扫码登录/微信登录等第三方认证
// 若未通过验证可以抛出异常
throw new ServiceException("publicKey不正确或者id_token过期!");
// 否则表示认证通过,返回一个Token对象
return new XXXAuthenticationToken(xxxInfoData, xxxInfoData.getName());
}
@Override
public boolean supports(Class<?> authentication) {
// 将第一步新建的token进行标记,建立provider和token之间的联系(此处呼应ProviderManager如何实现关联关系)
return XXXAuthenticationToken.class.isAssignableFrom(authentication);
}
}
注册自定义Provider到providerManage中
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
// 进行注册
auth.authenticationProvider(xxxAuthenticationProvider);
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
Service逻辑可以参照ruoyi实现
/**
* xxx登录
* @param xxxInfoData
* @return
*/
public String loginForXXX(XxxInfoData xxxInfoData){
// 用户验证
Authentication authentication = null;
try
{
Authentication xxxAuthenticationToken = new XXXAuthenticationToken(xxxInfoData, xxxInfoData.getIdToken());
AuthenticationContextHolder.setContext(xxxAuthenticationToken);
// 该方法会去调用 XXXAuthenticationProvider.authenticate
authentication = authenticationManager.authenticate(xxxAuthenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(authentication != null ? (String) authentication.getCredentials() : XXX_DEFAULT_USERNAME, Constants.LOGIN_FAIL, MessageUtils.message("user.id_token.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(authentication != null ? (String) authentication.getCredentials() : XXX_DEFAULT_USERNAME, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(authentication != null ? (String) authentication.getCredentials() : XXX_DEFAULT_USERNAME, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
XXXInfoData ipdUserData = (XXXInfoData) authentication.getPrincipal();
LoginUser loginUser = new LoginUser();
loginUser.setUser(new SysUser());
// 暂时无需对接我方用户
// recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
通过分析SpringSecurity框架,可以发现一个好的框架具有极佳的可扩展性,支持用较简单的方法进行自定义扩展。
参考资料: