Shiro
Apache Shiro 是 Java 的一个安全框架。相比于 Spring Security,它足够小巧和轻量级。
外部结构
从外部结构来看 Shiro一共包含三部分,分别如下:
- Subject:主体,代表了当前 “登录用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是 Subject
- SecurityManager:安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互
- Realm:域,Shiro 从 Realm 获取用户、角色、权限,就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。
简单理解就是 Subject
是外观模式的外观,它负责和应用程序交互,SecurityManager
负责协调框架内其他组件的功能实现,而Realm
类似一个 Repository 则负责用户安全数据的读取,主要是用户信息以及角色/权限等。
内部结构
内部结构可以看清 Shiro 内部负责的功能,各模块职责如下:
- Subject:主体,代表当前的 “登录用户”
- SecurityManager:Shiro 的核心;所有具体的交互都通过 SecurityManager 进行控制;它管理着所有 Subject、且负责进行认证和授权、及会话、缓存的管理。
- Authenticator:认证器,这是一个扩展点,如果用户觉得 Shiro 默认的不好,可以自定义实现;其需要认证策略(Authentication Strategy),即什么情况下算用户认证通过了;
- Authorizer:授权器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
- Realm:可以有 1 个或多个 Realm,可以是 JDBC 实现,也可以是 LDAP 实现,或者内存实现等等;
- SessionManager:Session 需要有人去管理它的生命周期,这个组件就是 SessionManager;而 Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境、EJB 等环境;所以呢,Shiro 就抽象了一个自己的 Session 来管理主体与应用之间交互的数据;这样的话,比如我们在 Web 环境用,刚开始是一台 Web 服务器;接着又上了台 EJB 服务器;这时想把两台服务器的会话数据放到一个地方,这个时候就可以实现自己的分布式会话(如把数据放到 Memcached 服务器);
- SessionDAO:比如我们想把 Session 保存到数据库,可以实现自己的 SessionDAO,通过如 JDBC 写到数据库;另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能;
- CacheManager:缓存控制器,来管理如用户、角色、权限等的缓存的
- Cryptography:密码模块,Shiro 提供了一些常见的加密组件用于如密码加密 / 解密的。
交互过程流程图
认证流程
Shiro 的原理同样是使用过滤器过滤请求,对请求进行认证和授权,Shiro 默认内置了一些过滤器,都集中在 org.apache.shiro.web.filter.mgt.DefaultFilter
public enum DefaultFilter {
anon(AnonymousFilter.class),
authc(FormAuthenticationFilter.class),
authcBasic(BasicHttpAuthenticationFilter.class),
authcBearer(BearerHttpAuthenticationFilter.class),
logout(LogoutFilter.class),
noSessionCreation(NoSessionCreationFilter.class),
perms(PermissionsAuthorizationFilter.class),
port(PortFilter.class),
rest(HttpMethodPermissionFilter.class),
roles(RolesAuthorizationFilter.class),
ssl(SslFilter.class),
user(UserFilter.class),
invalidRequest(InvalidRequestFilter.class);
前面代表的是过滤器的功能,比如 authc
代表的是认证功能过滤器,走的就是 FormAuthenticationFilter
这个过滤器
★★★如果后端报错,最好还是断点打在 AccessControlFilter
的 onPreHandle
方法看下
认证过程流程图:
上面 Shrio
外部流程中了解到 SecurityManager
和 Realm是核心组件,流程图中 DefaultSecurityManager
和 UserRealm
就是这两个组件的实现,下面详细看下组件实现:
DefaultSecurityManager
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = authenticate(token);
} catch (AuthenticationException ae) {
onFailedLogin(token, ae, subject);
}
// 该方法主要功能在流程图中已经说明
Subject loggedIn = createSubject(token, info, subject);
// 记住我
onSuccessfulLogin(token, info, loggedIn);
return loggedIn;
}
public Subject createSubject(SubjectContext subjectContext) {
SubjectContext context = copy(subjectContext);
......
Subject subject = doCreateSubject(context);
// 把 Subject 保存到 Session,如果 Session 不存在,则创建新的
// 如果 cookie 启用,设置 cookie
save(subject);
return subject;
}
// 登录成功后设置 RememberMe 功能
protected void onSuccessfulLogin(AuthenticationToken token, AuthenticationInfo info, Subject subject) {
rememberMeSuccessfulLogin(token, info, subject);
}
上面 createSubject
方法执行完在 redis中会存储 session
信息,key 是默认名称代表当前活跃用户,field 是默认 JavaUuidSessionIdGenerator
生成器生成的 sessionId(uuid),sessionId 生成器可由用户自定义,该 sessionId 就是返回给页面 cookie 的值,如下图所示:
AbstractAuthenticator
public final AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = doAuthenticate(token);
} catch (Throwable t) {
notifyFailure(token, ae);
}
notifySuccess(token, info);
return info;
}
protected void notifySuccess(AuthenticationToken token, AuthenticationInfo info) {
for (AuthenticationListener listener : this.listeners) {
listener.onSuccess(token, info);
}
}
AuthenticatingRealm
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 先从缓存获取认证信息
AuthenticationInfo info = getCachedAuthenticationInfo(token);
if (info == null) {
info = doGetAuthenticationInfo(token);
if (token != null && info != null) {
// 如果启用了缓存的话会将认证信息存放到缓存,如下图所示
cacheAuthenticationInfoIfPossible(token, info);
}
}
if (info != null) {
// 验证密码是否正确
assertCredentialsMatch(token, info);
}
return info;
}
private AuthenticationInfo getCachedAuthenticationInfo(AuthenticationToken token) {
AuthenticationInfo info = null;
// 从 CacheManager 里获取认证缓存对象,缓存名称是自定义的,默认是当前类名 + .authenticationCache
Cache<Object, AuthenticationInfo> cache = getAvailableAuthenticationCache();
if (cache != null && token != null) {
// 获取缓存 key,取的是当前认证用户名称
Object key = getAuthenticationCacheKey(token);
info = cache.get(key);
}
return info;
}
private void cacheAuthenticationInfoIfPossible(AuthenticationToken token, AuthenticationInfo info) {
if (!isAuthenticationCachingEnabled(token, info)) {
return;
}
// 从 CacheManager 里获取认证缓存对象,缓存名称是自定义的,默认是当前类名 + .authenticationCache
Cache<Object, AuthenticationInfo> cache = getAvailableAuthenticationCache();
if (cache != null) {
// 获取缓存 key,取的是当前认证用户名称
Object key = getAuthenticationCacheKey(token);
cache.put(key, info);
}
}
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
CredentialsMatcher cm = getCredentialsMatcher();
if (cm != null) {
if (!cm.doCredentialsMatch(token, info)) {
throw new IncorrectCredentialsException(msg);
}
}
}
执行完 cacheAuthenticationInfoIfPossible
后在 redis里会缓存当前认证用户信息,如下图所示:
这里的 key 是下图 UserRealm
中配置的名称,field 是当前认证用户的用户名,value 则是认证用户信息
关于密码比较 shiro
默认提供了 SimpleCredentialsMatcher
,比较方式是把密码转成 byte[] 然后进行比较
这种比较方式数据库存储的是明文且无法设置密码输入次数,所以一般需要自定义密码比较器,示例如下:
public class RetryLimitCredentialsMatcher extends HashedCredentialsMatcher {
private final RedissonClient redisson;
public RetryLimitCredentialsMatcher(RedissonManager redissonManager) {
this.redisson = redissonManager.getRedisson();
super.setHashAlgorithmName(ShiroConstant.HASH_ALGORITHM);
super.setHashIterations(ShiroConstant.HASH_ITERATIONS);
}
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
String loginUser = (String) token.getPrincipal();
RAtomicLong retryTimes = redisson.getAtomicLong(ShiroConstant.RETRY_lIMIT_CACHE + loginUser);
if (retryTimes.getAndIncrement() >= ShiroConstant.MAX_RETRY_TIMES) {
// 驳回登录请求
throw new RuntimeException("密码错误超过3次,请30分钟后重试");
}
retryTimes.expire(30, TimeUnit.MINUTES);
// 验证账户密码,如果登录成功,清除缓存
if (super.doCredentialsMatch(token, info)) {
retryTimes.delete();
return true;
}
log.error("密码错误,登录用户:{},重试次数:{}",loginUser, retryTimes);
// 密码验证失败
return false;
}
}
UserRealm
UserRealm 是最终操作数据库的地方,由用户自定义并实现 AuthorizingRealm
鉴权接口,自定义认证和授权逻辑
public class UserRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
// 认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String loginUser = (String) token.getPrincipal();
LambdaQueryWrapper<User> condition = new LambdaQueryWrapper<User>()
.eq(User::getUsername, loginUser);
User user = userService.getOne(condition);
if (user == null) {
return null; // 返回null 在ModularRealmAuthenticator[184] 会抛出UnknownAccountException
}
return new SimpleAuthenticationInfo(loginUser, user.getPassword(), ShiroByteSource.Util.bytes("595f81557f9e403990fecea2d2e177e8"), getName());
}
}
SimpleAuthenticationInfo
是 AuthenticationInfo
的默认实现,代表当前认证用户信息,也就是要缓存到 redis 里的值。
授权流程
前面认证流程中用户在登录过后 Shiro
已经生成了 cookie返回到浏览器,浏览器携带 token/cookie
访问服务器,服务器进行授权,Shiro提供了三种方式的授权:
- 编程式:
Subject subject = SecurityUtils.getSubject();
subject.hasRole("admin")
- 注解
@RequiresRoles("admin")
- GSP 标签
<shiro:hasRole name="admin">
<!— 有权限 —>
</shiro:hasRole>
下面以编程式为例看下授权流程,假设我有如下接口定义:
@PostMapping("/pageCondition/{current}/{size}")
public R pageCondition(@PathVariable long current,
@PathVariable long size,
@RequestBody(required = false) UserVo userVo) {
// 校验用户是否有 "user:view" 权限
Subject subject = SecurityUtils.getSubject();
subject.checkPermission("user:view");
Page<User> userPage = userService.pageCondition(Page.of(current, size), userVo);
return R.success(userPage);
}
授权流程图如下:
下面详细看下组件实现:
ModularRealmAuthorizer
public void checkPermission(PrincipalCollection principals, String permission) throws AuthorizationException {
assertRealmsConfigured();
if (!isPermitted(principals, permission)) {
throw new UnauthorizedException("Subject does not have permission [" + permission + "]");
}
}
public boolean isPermitted(PrincipalCollection principals, String permission) {
assertRealmsConfigured();
for (Realm realm : getRealms()) {
if (!(realm instanceof Authorizer)) continue;
if (((Authorizer) realm).isPermitted(principals, permission)) {
return true;
}
}
return false;
}
AuthorizingRealm
public boolean isPermitted(PrincipalCollection principals, Permission permission) {
AuthorizationInfo info = getAuthorizationInfo(principals);
return isPermitted(permission, info);
}
protected AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals) {
// principals 是从 session 中取出的认证用户信息,主要包含用户名、密码
if (principals == null) {
return null;
}
AuthorizationInfo info = null;
// 从 CacheManager 里获取授权缓存对象,缓存名称是自定义的,默认是当前类名 + .authorizationCache
Cache<Object, AuthorizationInfo> cache = getAvailableAuthorizationCache();
if (cache != null) {
// key 取的是缓存对象,不是缓存对象字符串名,这里和认证是有所区别的
Object key = getAuthorizationCacheKey(principals);
info = cache.get(key);
}
if (info == null) {
// 调 UserRealm 方法从 DB 取授权信息
info = doGetAuthorizationInfo(principals);
if (info != null && cache != null) {
Object key = getAuthorizationCacheKey(principals);
// 缓存授权信息
cache.put(key, info);
}
}
return info;
}
// permission 代表的是授权站点的访问权限,也就是接口上配置的 "user:view"
protected boolean isPermitted(Permission permission, AuthorizationInfo info) {
// 把授权对象中角色权限信息封装成 Permission 列表
Collection<Permission> perms = getPermissions(info);
if (perms != null && !perms.isEmpty()) {
// 遍历权限列表和授权站点访问权限进行对比
for (Permission perm : perms) {
if (perm.implies(permission)) {
return true;
}
}
}
return false;
}
redis 中缓存的权限信息:
UserRealm
public class UserRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private MenuService menuService;
// 授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String loginUser = (String) principals.getPrimaryPrincipal();
LambdaQueryWrapper<User> condition = new LambdaQueryWrapper<User>()
.eq(User::getUsername, loginUser);
User user = userService.getOne(condition);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
if (null != user.getIsAdmin() && user.getIsAdmin()) {
List<Role> roles = roleService.list();
roles.forEach(role -> info.addRole(role.getRoleCode()));
List<Menu> menus = menuService.list();
menus.forEach(menu -> info.addStringPermission(menu.getPermission()));
return info;
}
List<Role> roles = roleService.getUserRoles(user.getId());
roles.forEach(role -> info.addRole(role.getRoleCode()));
List<Menu> menus = menuService.getUserMenus(user.getId());
menus.forEach(menu -> info.addStringPermission(menu.getPermission()));
return info;
}
}
YML 配置过滤器方法
在 application.yml 中配置列表用如下方法不生效
shiro:
filter:
chain:
- /swagger/**=anon
- /webjars/**=anon
- /statics/**=anon
- /login.html=anon
- /login=anon
- /**=authc
需按如下方式配置
shiro:
filter:
chain: >
/swagger/**=anon,
/webjars/**=anon,
/statics/**=anon,
/login.html=anon,
/login=anon,
/**=authc
前后端分离会话问题
用于浏览器是基于 cookie 的,如果用户浏览器禁用了 cookie,每次都要访问都要重新登录
所以提供了基于 JWT 的 TOKEN 解决方案
1、用户登录后,生成 sessionId,使用 JWT 根据 sessionId 颁发签名并设置过期时间(和session 过期时间保持一致),返回token
2、客户端将 token 保存到 localStorage,每次请求都在 header 上携带 token
3、ShiroSessionManager继承DefaultWebSessionManager,重写 getSessionId() 方法,从header上检测是否携带 token,如果携带,进行解码,使用 token 中的 jti 作为 sessionId。
4、重写shiro的默认过滤器,使其支持 jwttoke的有效期校验以及对 json 的支持
- JwtAuthcFilter,是否需要登录的过滤,拒绝时如果 header 上携带 token,返回对应 json
- JwtRoleFilter,是否需要对应角色的过滤,拒绝时如果 header 上携带 token,返回对应 json
- JwtPermFilter,是否需要对应权限的过滤,拒绝时如果 header 上携带 token,返回对应 json
重写默认过滤器的原因是默认过滤器如果鉴权失败会重定向到登录页面,而前后端分离的前端更希望的是返回一个json而不是在后端重定向到登录页。
Redis 反序列化问题
shiro 使用 redis 做缓存,我这里遇到最多的问题就是反序列化问题,因为需要存储到 reids 中的认证信息 AuthenticationInfo
中有几个属性只有 getter
而没有 setter
,如果 redis 使用的序列化器是 json 的那几个,就导致反序列化的时候失败,关于这个问题在网上也搜了好久,最终得到一个勉强的解决方案,下面是我的序列化器实现:
@Slf4j
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory){
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//忽略JSON字符串中存在而Java对象实际没有的属性
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
// 此项必须配置,否则会报java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to XXX
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
String[] excludeSerialize = {"realmNames", "attributeKeys", "attributesLazy"};
objectMapper.addMixIn(SimplePrincipalCollection.class, ExcludeSerializedField.class);
objectMapper.addMixIn(SimpleSession.class, ExcludeSerializedField.class);
objectMapper.setFilterProvider(new SimpleFilterProvider().addFilter("shiroJsonFilter", SimpleBeanPropertyFilter.serializeAllExcept(excludeSerialize)));
RedisTemplate<String,Object>template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashKeySerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
return template;
}
}
代码中关于 mixin
部分参考文章:
shiro使用Jackson2JsonRedisSerializer整合redis实现认证与授权的坑
文章中作者使用的是只序列化需要的属性,考虑到不知道哪些属性可能有其他的地方用到,所以我这里是不序列化不需要的属性,这个可以看自己的需求。
另外说一下,其实在使用下来我发现其实 redis 中的值大部分情况下自己是不会去看的,所以使用 json 的序列化方式其实没有太大的作用,如果不是必须的还是是二进制的更方便一些,也避免以后有其他问题
saveRequest 问题
/**
* Shrio 中未经身份验证的用户访问需要身份验证的页面,会预先将该请求地址存放到 session
* 待登录成功后在重定向到该页面 {@link AuthenticationFilter#issueSuccessRedirect}
* 但是 {@link SavedRequest} 类没有默认构造方法导致 redis 反序列化失败,这个类的作用
* 是不在缓存预先访问的地址,重写 saveRequestAndRedirectToLogin 方法。
*
* @author Songwe
* @since 2021/4/2 21:11
*/
public class GiveUpSaveRequestFilter extends FormAuthenticationFilter {
@Override
protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
redirectToLogin(request, response);
}
}
将上面过滤器加入 Shrio 默认过滤器链如下:
@Bean
public ShiroFilterFactoryBean shiroFilter(RedissonManager redissonManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager());
Map<String, Filter> filters = new HashMap<>();
filters.put("no_save_req", new GiveUpSaveRequestFilter());
shiroFilterFactoryBean.setFilters(filters);
Map<String, String> filterMap = new HashMap<>(16);
filterMap.put("/login", "anon");
filterMap.put("/logout", "anon");
filterMap.put("/**", "kick_out, no_save_req, authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
// 设置登录地址,未登录时访问未授权站点也调转到这里
shiroFilterFactoryBean.setLoginUrl("/login.html");
// 设置登录后未授权站点跳转
shiroFilterFactoryBean.setUnauthorizedUrl("/403");
return shiroFilterFactoryBean;
}
跨域问题
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
HttpServletRequest request = (HttpServletRequest) req;
response.setHeader("Access-Control-Allow-Origin", "http://localhost:9528");
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "X-Token,Content-Type,Content-Length, Authorization, Accept,X-Requested-With,domain,zdy");
if(request.getMethod().equals(HttpMethod.OPTIONS.name())){
response.setStatus(HttpStatus.NO_CONTENT.value());
}else{
chain.doFilter(req, res);
}
}
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void destroy() {}
}