Ruoyi框架学习笔记-Spring Security
<p>[TOC]</p>
<h1>1. 简介</h1>
<h1>2. 认证流程</h1>
<p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=6a84b7aa8143afc60c4fcb74fe802fba&amp;file=file.png" alt="" /></p>
<ol>
<li>
<p>用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter 过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。</p>
</li>
<li>
<p>然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证</p>
</li>
<li>
<p>认证成功后, AuthenticationManager 身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除) Authentication 实例。</p>
</li>
<li>SecurityContextHolder 安全上下文容器将第3步填充了信息的Authentication ,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider> 列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终
AuthenticationProvider将UserDetails填充至Authentication。</li>
</ol>
<h1>3. Authentication、SecurityContext和SecurityContextHolder</h1>
<h2>3.0 认证登录触发调用</h2>
<pre><code class="language-java">authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));</code></pre>
<p>ruoyi完整登录逻辑:</p>
<pre><code class="language-java">/**
* 登录验证(每次登录都生成了新的Jwt令牌,有效期30分钟)
* AuthenticationManager.authenticate()
* -&gt;委托认证DaoAuthenticationProvider.authenticate()
* -&gt;获取用户信息UserDetailsService.loadUserByUsername()
* -&gt;DaoAuthenticationProvider通过PasswordEncoder对比UserDetails中的密码与Authentication中的密码是否一致
* -&gt;DaoAuthenticationProvider填充Authentication,如权限信息
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果:令牌
*/
public String login(String username, String password, String code, String uuid)
{
boolean captchaOnOff = configService.selectCaptchaOnOff();
// 1.验证码开关
if (captchaOnOff)
{
//验证码开关True:校验验证码
validateCaptcha(username, code, uuid);
}
// 2.用户验证
Authentication authentication = null;
try
{
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
//会对查询到的UserDetails实现类(LoginUser)对象的isAccountNonExpired等方法进行验证
//会对用户传入的密码和数据库保存密码进行匹配验证
// authentication.getPrincipal()可以获取到UserDetails的实现类实例,需要加强制转换
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{//BadCredentialsException:密码不匹配异常
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message(&quot;user.password.not.match&quot;)));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
//3.记录用户登录日志(sys_logininfor表)
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message(&quot;user.login.success&quot;)));
//4.更新用户表的登录信息
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
//更新登录用户的基本信息到sys_user表
recordLoginInfo(loginUser.getUserId());
//5.生成Jwt令牌(令牌可以获取缓存中的用户信息)
return tokenService.createToken(loginUser);
}</code></pre>
<h2>3.1 Authentication</h2>
<p>authentication 直译过来是“认证”的意思,在Spring Security 中Authentication用来表示当前用户是谁,是一个接口,UsernamePasswordAuthenticationToken就是Authentication的一个实现类,一般来讲你可以理解为authentication就是一组用户名密码信息。</p>
<pre><code class="language-java">public interface Authentication extends Principal, Serializable {
Collection&lt;? extends GrantedAuthority&gt; getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean var1) throws IllegalArgumentException;
}</code></pre>
<ol>
<li>Authentication是spring security包中的接口,直接继承自Principal类,而Principal是位于java.security包中的。它是表示着一个抽象主体身份,任何主体都有一个名称,因此包含一个getName()方法。</li>
<li>getAuthorities(),权限信息列表,默认是GrantedAuthority接口的一些实现类,通常是代表权限信息的一系列字符串。</li>
<li>getCredentials(),凭证信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。</li>
<li>getDetails(),细节信息,web应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的ip地址和sessionId的值。</li>
<li><strong>getPrincipal()</strong>,身份信息,大部分情况下返回的是UserDetails接口的实现类,UserDetails代表用户的详细信息,那从Authentication中取出来的UserDetails就是当前登录用户信息,它也是框架中的常用接口之一。未认证的情况下获取到的是用户名。</li>
</ol>
<h2>3.2 SecurityContext</h2>
<p>安全上下文,用户通过Spring Security 的校验之后,验证信息存储在SecurityContext中,SecurityContext的接口定义如下:</p>
<pre><code class="language-java">public interface SecurityContext extends Serializable {
/**
* 获取当前已认证的主体或认证请求令牌
*/
Authentication getAuthentication();
/**
* 更改当前已认证的主体,或删除认证信息
*/
void setAuthentication(Authentication authentication);
}</code></pre>
<h2>3.3 SecurityContextHolder</h2>
<p>安全上线文容器:在请求开始时从配置好的SecurityContextRepository中获取SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 SecurityContextHolder 所持有的SecurityContext。</p>
<pre><code class="language-java">/**
* 获取Authentication
*/
public static Authentication getAuthentication()
{
return SecurityContextHolder.getContext().getAuthentication();
}</code></pre>
<h1>4. AuthenticationManager和AuthenticationProvider</h1>
<h2>4.1 AuthenticationManager</h2>
<p>AuthenticationManager是一个接口,它只有一个方法,接收参数为Authentication,其定义如下:</p>
<pre><code class="language-java">public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}</code></pre>
<p>AuthenticationManager 的作用就是校验Authentication,如果验证失败会抛出AuthenticationException异常。AuthenticationException是一个抽象类,因此代码逻辑并不能实例化一个AuthenticationException异常并抛出,实际上抛出的异常通常是其实现类,如DisabledException,LockedException,BadCredentialsException等。BadCredentialsException可能会比较常见,即密码错误的时候。</p>
<h2>4.2 AuthenticationProvider</h2>
<p>通过前面的Spring Security认证流程我们得知,认证管理器(AuthenticationManager)委托
AuthenticationProvider完成认证工作。AuthenticationProvider是一个接口,定义如下:</p>
<pre><code class="language-java">public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class&lt;?&gt; var1);
}</code></pre>
<p>authenticate()方法定义了认证的实现过程,它的参数是一个Authentication,里面包含了登录用户所提交的用户、密码等。而返回值也是一个Authentication,这个Authentication则是在认证成功后,将用户的权限及其他信息重新组装后生成。
Spring Security中维护着一个List<AuthenticationProvider> 列表,存放多种认证方式,不同的认证方式使用不同的AuthenticationProvider。如使用用户名密码登录时,使用AuthenticationProvider1,短信登录时使用AuthenticationProvider2等等这样的例子很多。
每个AuthenticationProvider需要实现supports()方法来表明自己支持的认证方式,如我们使用表单方式认证,在提交请求时Spring Security会生成UsernamePasswordAuthenticationToken,它是一个Authentication,里面封装着用户提交的用户名、密码信息。而对应的,哪个AuthenticationProvider来处理它?我们在DaoAuthenticationProvider的基类AbstractUserDetailsAuthenticationProvider发现以下代码:</p>
<pre><code class="language-java">public boolean supports(Class&lt;?&gt; authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}</code></pre>
<p>也就是说当web表单提交用户名密码时,Spring Security由DaoAuthenticationProvider处理。</p>
<h1>5. UserDetails和UserDetailsService的实现:LoginUser和UserDetailsServiceImpl</h1>
<h2>5.1 UserDetails和其实现LoginUser</h2>
<p>UserDetails存储的就是用户信息,其定义如下:</p>
<pre><code class="language-java">public interface UserDetails extends Serializable {
Collection&lt;? extends GrantedAuthority&gt; getAuthorities(); //获取用户权限
String getPassword(); //获取密码
String getUsername(); //获取用户名
boolean isAccountNonExpired(); //账户是否过期
boolean isAccountNonLocked(); //账户是否被锁定
boolean isCredentialsNonExpired(); //密码是否过期
boolean isEnabled(); //账户是否可用
}</code></pre>
<p>它和Authentication接口很类似,比如它们都拥有username,authorities。Authentication的getCredentials()与UserDetails中的getPassword()需要被区分对待,前者是用户提交的密码凭证,后者是用户实际存储的密码,认证其实就是对这两者的比对。Authentication中的getAuthorities()实际是由UserDetails的getAuthorities()传递而形成的。还记得Authentication接口中的getDetails()方法吗?其中的UserDetails用户详细信息便是经过了AuthenticationProvider认证之后被填充的。</p>
<p>实现类LoginUser定义:</p>
<pre><code class="language-java">/**
* 登录用户身份权限
* 1.实现了UserDetails
* 2.封装了用户信息:SysUser
* 3.用户权限集合:Set&lt;String&gt;
*
* @author ruoyi
*/
public class LoginUser implements UserDetails
{
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 部门ID
*/
private Long deptId;
/**
* 用户唯一标识
*/
private String token;
/**
* 登录时间
*/
private Long loginTime;
/**
* 过期时间
*/
private Long expireTime;
/**
* 登录IP地址
*/
private String ipaddr;
/**
* 登录地点
*/
private String loginLocation;
/**
* 浏览器类型
*/
private String browser;
/**
* 操作系统
*/
private String os;
/**
* 权限列表
*/
private Set&lt;String&gt; permissions;
/**
* 用户信息
*/
private SysUser user;
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public Long getDeptId()
{
return deptId;
}
public void setDeptId(Long deptId)
{
this.deptId = deptId;
}
public String getToken()
{
return token;
}
public void setToken(String token)
{
this.token = token;
}
public LoginUser()
{
}
public LoginUser(SysUser user, Set&lt;String&gt; permissions)
{
this.user = user;
this.permissions = permissions;
}
public LoginUser(Long userId, Long deptId, SysUser user, Set&lt;String&gt; permissions)
{
this.userId = userId;
this.deptId = deptId;
this.user = user;
this.permissions = permissions;
}
@JSONField(serialize = false)
@Override
public String getPassword()
{
return user.getPassword();
}
@Override
public String getUsername()
{
return user.getUserName();
}
/**
* 账户是否未过期,过期无法验证
*/
@JSONField(serialize = false)
@Override
public boolean isAccountNonExpired()
{
return true;
}
/**
* 指定用户是否解锁,锁定的用户无法进行身份验证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isAccountNonLocked()
{
return true;
}
/**
* 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isCredentialsNonExpired()
{
return true;
}
/**
* 是否可用 ,禁用的用户不能身份验证
*
* @return
*/
@JSONField(serialize = false)
@Override
public boolean isEnabled()
{
return true;
}
public Long getLoginTime()
{
return loginTime;
}
public void setLoginTime(Long loginTime)
{
this.loginTime = loginTime;
}
public String getIpaddr()
{
return ipaddr;
}
public void setIpaddr(String ipaddr)
{
this.ipaddr = ipaddr;
}
public String getLoginLocation()
{
return loginLocation;
}
public void setLoginLocation(String loginLocation)
{
this.loginLocation = loginLocation;
}
public String getBrowser()
{
return browser;
}
public void setBrowser(String browser)
{
this.browser = browser;
}
public String getOs()
{
return os;
}
public void setOs(String os)
{
this.os = os;
}
public Long getExpireTime()
{
return expireTime;
}
public void setExpireTime(Long expireTime)
{
this.expireTime = expireTime;
}
public Set&lt;String&gt; getPermissions()
{
return permissions;
}
public void setPermissions(Set&lt;String&gt; permissions)
{
this.permissions = permissions;
}
public SysUser getUser()
{
return user;
}
public void setUser(SysUser user)
{
this.user = user;
}
@Override
public Collection&lt;? extends GrantedAuthority&gt; getAuthorities()
{
return null;
}
}</code></pre>
<h2>5.2 UserDetailsService和其实现UserDetailsServiceImpl</h2>
<p>UserDetailService只负责从特定的地方(通常是数据库)加载用户信息,自定义UserDetailsService如下:</p>
<pre><code class="language-java">/**
* 用户验证处理
*
* @author ruoyi
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Autowired
private ISysUserService userService;
@Autowired
private SysPermissionService permissionService;
/**
* 获取数据库中username对应的用户信息,并封装成UserDetails类
* @param username 1
* @return: org.springframework.security.core.userdetails.UserDetails
* @author: gefeng
* @date: 2022/6/13 19:14
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user))
{
log.info(&quot;登录用户:{} 不存在.&quot;, username);
throw new ServiceException(&quot;登录用户:&quot; + username + &quot; 不存在&quot;);
}
else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
{
log.info(&quot;登录用户:{} 已被删除.&quot;, username);
throw new ServiceException(&quot;对不起,您的账号:&quot; + username + &quot; 已被删除&quot;);
}
else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
{
log.info(&quot;登录用户:{} 已被停用.&quot;, username);
throw new ServiceException(&quot;对不起,您的账号:&quot; + username + &quot; 已停用&quot;);
}
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user)
{
//根据userId、部门id、系统用户和用户权限集合(根据用户ID查询)创建LoginUser对象
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
}</code></pre>
<h1>6. 授权流程</h1>
<h2>6.1 授权流程</h2>
<p>Spring Security可以通过http.authorizeRequests() 对web请求进行授权保护。Spring
Security使用标准Filter建立了对web请求的拦截,最终实现对资源的授权访问。
Spring Security的授权流程如下:
<img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=0f8630b4662a7a516f349eafc18cffc3&amp;file=file.png" alt="" /></p>
<p>分析授权流程:</p>
<ol>
<li>拦截请求,已认证用户访问受保护的web资源将被SecurityFilterChain中的FilterSecurityInterceptor 的子
类拦截。</li>
<li>获取资源访问策略,FilterSecurityInterceptor会从SecurityMetadataSource 的子类
DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限
Collection<ConfigAttribute> 。
SecurityMetadataSource其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则, 读
取访问策略如:
<pre><code class="language-java">http
.authorizeRequests( )
.antMatchers(&quot;/r/r1&quot;).hasAuthority(&quot;p1&quot;)
.antMatchers(&quot;/r/r2&quot;).hasAuthority(&quot;p2&quot;)</code></pre></li>
<li>最后,FilterSecurityInterceptor会调用AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。</li>
</ol>
<h2>6.2 授权决策</h2>
<p>AccessDecisionManager(访问决策管理器)的核心接口如下:</p>
<pre><code class="language-java">public interface AccessDecisionManager {
/**
* 通过传递的参数来决定用户是否有访问对应受保护资源的权限
*/
void decide(Authentication authentication , Object object, Collection&lt;ConfigAttribute&gt;
configAttributes ) throws AccessDeniedException, InsufficientAuthenticationException;
//略..
}</code></pre>
<p>这里着重说明一下decide的参数:</p>
<ul>
<li>authentication:要访问资源的访问者的身份</li>
<li>object:要访问的受保护资源,web请求对应FilterInvocation</li>
<li>configAttributes:是受保护资源的访问策略,通过SecurityMetadataSource获取。</li>
<li>decide接口就是用来鉴定当前用户是否有访问对应受保护资源的权限。</li>
</ul>
<p>AccessDecisionManager采用投票的方式来确定是否能够访问受保护资源。
<img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=1b43706e3794dcff397c0c38d84e3150&amp;file=file.png" alt="" />
通过上图可以看出,AccessDecisionManager中包含的一系列AccessDecisionVoter将会被用来对Authentication是否有权访问受保护对象进行投票,AccessDecisionManager根据投票结果,做出最终决策。AccessDecisionVoter是一个接口,其中定义有三个方法,具体结构如下所示。</p>
<pre><code class="language-java">public interface AccessDecisionVoter&lt;S&gt; {
int ACCESS_GRANTED = 1;
int ACCESS_ABSTAIN = 0;
int ACCESS_DENIED = ‐1;
boolean supports(ConfigAttribute var1);
boolean supports(Class&lt;?&gt; var1);
int vote(Authentication var1, S var2, Collection&lt;ConfigAttribute&gt; var3);
}</code></pre>
<p>vote()方法的返回结果会是AccessDecisionVoter中定义的三个常量之一。ACCESS_GRANTED表示同意,
ACCESS_DENIED表示拒绝,ACCESS_ABSTAIN表示弃权。如果一个AccessDecisionVoter不能判定当前
Authentication是否拥有访问对应受保护对象的权限,则其vote()方法的返回值应当为弃权ACCESS_ABSTAIN。</p>
<h2>6.4 Web授权</h2>
<p>通过给http.authorizeRequests() 添加多个子节点来定制需求到我们的URL,如下代码:</p>
<pre><code class="language-java">@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests() //(1)
.antMatchers(&quot;/r/r1&quot;).hasAuthority(&quot;p1&quot;) //(2)
.antMatchers(&quot;/r/r2&quot;).hasAuthority(&quot;p2&quot;) //(3)
.antMatchers(&quot;/r/r3&quot;).access(&quot;hasAuthority('p1') and hasAuthority('p2')&quot;) //(4)
.antMatchers(&quot;/r/**&quot;).authenticated() //(5)
.anyRequest().permitAll() //(6)
.and()
.formLogin()
// ...
}</code></pre>
<p>(1)http.authorizeRequests() 方法有多个子节点,每个macher按照他们的声明顺序执行。
(2)指定"/r/r1"URL,拥有p1权限能够访问
(3)指定"/r/r2"URL,拥有p2权限能够访问
(4)指定了"/r/r3"URL,同时拥有p1和p2权限才能够访问
(5)指定了除了r1、r2、r3之外"/r/**"资源,同时通过身份认证就能够访问,这里使用SpEL(Spring Expression Language)表达式。
(6)剩余的尚未匹配的资源,不做保护。</p>
<p>注意:
规则的顺序是重要的,更具体的规则应该先写.现在以/admin开始的所有内容都需要具有ADMIN角色的身份验证用户,即使是/admin/login路径(因为/admin/login已经被/admin/**规则匹配,因此第二个规则被忽略).</p>
<pre><code class="language-java">.antMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
.antMatchers(&quot;/admin/login&quot;).permitAll()</code></pre>
<p>因此,登录页面的规则应该在/ admin / **规则之前.例如.</p>
<pre><code class="language-java">.antMatchers(&quot;/admin/login&quot;).permitAll()
.antMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)</code></pre>
<p>保护URL常用的方法有:</p>
<ul>
<li>authenticated() 保护URL,需要用户登录</li>
<li>permitAll() 指定URL无需保护,一般应用与静态资源文件</li>
<li>hasRole(String role) 限制单个角色访问,角色将被增加 “ROLE_” .所以”ADMIN” 将和 “ROLE_ADMIN”进行比较.</li>
<li>hasAuthority(String authority) 限制单个权限访问</li>
<li>hasAnyRole(String… roles)允许多个角色访问.</li>
<li>hasAnyAuthority(String… authorities) 允许多个权限访问.</li>
<li>access(String attribute) 该方法使用 SpEL表达式, 所以可以创建复杂的限制.</li>
<li>hasIpAddress(String ipaddressExpression) 限制IP地址或子网</li>
</ul>
<h2>6.5 方法授权</h2>
<p>从Spring Security2.0版
本开始,它支持服务层方法的安全性的支持。本节学习@PreAuthorize,@PostAuthorize, @Secured三类注解。我们可以在任何@Configuration 实例上使用@EnableGlobalMethodSecurity 注释来启用基于注解的安全性。以下内容将启用Spring Security的@Secured 注释。</p>
<pre><code class="language-java">/**
* spring security配置
* prePostEnabled = true, securedEnabled = true:开启了三个注解
* @PreAuthorize:方法执行前进行权限检查
* @PostAuthorize:方法执行后进行权限检查
* @Secured:类似于 @PreAuthorize
* @author ruoyi
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{//...}</code></pre>
<p>然后向方法(在类或接口上)添加注解就会限制对该方法的访问。 Spring Security的原生注释支持为该方法定义了一组属性。 这些将被传递给AccessDecisionManager以供它作出实际的决定:</p>
<pre><code class="language-java">public interface BankService {
@Secured(&quot;IS_AUTHENTICATED_ANONYMOUSLY&quot;)
public Account readAccount(Long id);
@Secured(&quot;IS_AUTHENTICATED_ANONYMOUSLY&quot;)
public Account[] findAccounts();
@Secured(&quot;ROLE_TELLER&quot;)
public Account post(Account account, double amount);
}</code></pre>
<p>以上配置标明readAccount、findAccounts方法可匿名访问,底层使用WebExpressionVoter投票器,可从
AffirmativeBased第23行代码跟踪。post方法需要有TELLER角色才能访问,底层使用RoleVoter投票器。</p>
<pre><code class="language-java">public interface BankService {
@PreAuthorize(&quot;isAnonymous()&quot;)
public Account readAccount(Long id);
@PreAuthorize(&quot;isAnonymous()&quot;)
public Account[] findAccounts();
@PreAuthorize(&quot;hasAuthority('p_transfer') and hasAuthority('p_read_account')&quot;)
public Account post(Account account, double amount);
}</code></pre>
<p>以上配置标明readAccount、findAccounts方法可匿名访问,post方法需要同时拥有p_transfer和p_read_account权限才能访问,底层使用WebExpressionVoter投票器,可从AffirmativeBased第23行代码跟踪。</p>
<h1>7. Spring Security过滤器链</h1>
<p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=04b18aee7adf79927e5e3849cecc3acc&amp;file=file.png" alt="" /></p>
<ol>
<li>
<p>SecurityContextPersistenceFilter:这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;</p>
</li>
<li>
<p>UsernamePasswordAuthenticationFilter: 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和AuthenticationFailureHandler,这些都可以根据需求做相关改变;</p>
</li>
<li>
<p>ExceptionTranslationFilter: 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。</p>
</li>
<li>FilterSecurityInterceptor: 是用于保护web资源的,使用AccessDecisionManager对当前用户进行授权访问,前面已经详细介绍过了;</li>
</ol>
<h1>8. RuoYi-Vue自定义Handler、Filter和Interceptor</h1>
<h2>8.1 认证失败处理类-AuthenticationEntryPointImpl</h2>
<p>自定义AuthenticationEntryPointImpl实现AuthenticationEntryPoint</p>
<pre><code class="language-java">/**
* 认证失败处理类 返回未授权响应(HttpStatus.UNAUTHORIZED-401)
*
* @author ruoyi
*/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
{
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException
{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format(&quot;请求访问:{},认证失败,无法访问系统资源&quot;, request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}</code></pre>
<p>在SecurityConfig中添加配置</p>
<pre><code class="language-java">@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
httpSecurity
// 防止CSRF功能禁用,因为不使用session
.csrf().disable()
// 认证失败处理类(implements AuthenticationEntryPoint)
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
...
...
}</code></pre>
<h2>8.2 退出登录处理类-LogoutSuccessHandlerImpl</h2>
<p>自定义LogoutSuccessHandlerImpl实现LogoutSuccessHandler</p>
<pre><code class="language-java">/**
* 自定义退出处理类 返回操作成功(HttpStatus.SUCCESS-200)
*
* @author ruoyi
*/
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
@Autowired
private TokenService tokenService;
/**
* 退出处理
* 0. 从request中获取登录用户信息
* 1. 删除用户缓存记录
* 2. 记录用户退出日志
*
* @return
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException
{
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser))
{
String userName = loginUser.getUsername();
// 删除用户缓存记录
tokenService.delLoginUser(loginUser.getToken());
// 记录用户退出日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, &quot;退出成功&quot;));
}
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, &quot;退出成功&quot;)));
}
}</code></pre>
<p>在SecurityConfig中添加配置</p>
<pre><code class="language-java">@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
...
...
//退出登录处理:前端请求/logout就会进入处理类
httpSecurity.logout().logoutUrl(&quot;/logout&quot;).logoutSuccessHandler(logoutSuccessHandler);
...
...
}</code></pre>
<h2>8.3 token认证过滤器-JwtAuthenticationTokenFilter</h2>
<p>自定义JwtAuthenticationTokenFilter继承OncePerRequestFilter</p>
<pre><code class="language-java">/**
* token过滤器 验证token有效性
*
* @author ruoyi
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
/**
* 1.获取登录用户的身份信息(1.1从请求头中获取token,1.2从token中解析出uuid,1.3拼接出userKey去redis中获取value即为用户信息)
* 2.刷新token缓存有效期
* 3.将用户信息设置到SpringSecurity上下文
* 4.chain.doFilter(request, response)进入后续的过滤器(UsernamePasswordAuthenticationFilter)处理
* @param request 1
* @param response 2
* @param chain 3
* @return: void
* @author: gefeng
* @date: 2022/6/14 11:02
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
//1.获取登录用户的身份信息(1.1从请求头中获取token,1.2从token中解析出uuid,1.3拼接出userKey去redis中获取value即为用户信息)
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) &amp;&amp; StringUtils.isNull(SecurityUtils.getAuthentication()))
{
//2.刷新token缓存有效期
tokenService.verifyToken(loginUser);
//3.将用户信息设置到上下文
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
//用户信息为空,则未登录过滤掉
//进入后续的过滤器处理
chain.doFilter(request, response);
}
}</code></pre>
<p>在SecurityConfig中添加配置,在UsernamePasswordAuthenticationFilter之前</p>
<pre><code class="language-java">@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
...
...
// 添加JWT filter(在UsernamePasswordAuthenticationFilter之前)
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
...
...
}</code></pre>
<h2>8.4 跨域过滤器-CorsFilter</h2>
<pre><code class="language-java">/**
* 跨域配置
*/
@Bean
public CorsFilter corsFilter()
{
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
// 设置访问源地址
config.addAllowedOriginPattern(&quot;*&quot;);
// 设置访问源请求头
config.addAllowedHeader(&quot;*&quot;);
// 设置访问源请求方法
config.addAllowedMethod(&quot;*&quot;);
// 有效期 1800秒
config.setMaxAge(1800L);
// 添加映射路径,拦截一切请求
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration(&quot;/**&quot;, config);
// 返回新的CorsFilter
return new CorsFilter(source);
}</code></pre>
<p>在SecurityConfig中添加配置,在JwtAuthenticationTokenFilter和LogoutFilter之前</p>
<pre><code class="language-java">@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
...
...
// 添加CORS filter(在JwtAuthenticationTokenFilter和LogoutFilter之前)
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
...
...
}</code></pre>
<h2>8.5 防止重复提交拦截器-RepeatSubmitInterceptor</h2>
<p>先自定义一个抽象类RepeatSubmitInterceptor实现HandlerInterceptor</p>
<pre><code class="language-java">/**
* 防止重复提交拦截器抽象类
*
* @author ruoyi
*/
@Component
public abstract class RepeatSubmitInterceptor implements HandlerInterceptor
{
/**
* 对加了@RepeatSubmit注解的方法进行重复提交判断(判断规则由子类实现),若重复提交,进行拦截,响应报文返回码500和提示
* @param request 1
* @param response 2
* @param handler 3
* @return: boolean
* @author: gefeng
* @date: 2023/4/6 14:39
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
if (handler instanceof HandlerMethod)
{
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if (annotation != null)
{
if (this.isRepeatSubmit(request, annotation))
{
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
return false;
}
}
return true;
}
else
{
return true;
}
}
/**
* 验证是否重复提交由子类实现具体的防重复提交的规则
*
* @param request
* @return
* @throws Exception
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
}</code></pre>
<p>再自定义一个子类SameUrlDataInterceptor继承RepeatSubmitInterceptor实现重复提交的判断规则</p>
<pre><code class="language-java">/**
* 判断请求url和数据是否和上一次相同,
* 如果和上次相同,则是重复提交表单。 有效时间为10秒内。
*
* @author ruoyi
*/
@Component
public class SameUrlDataInterceptor extends RepeatSubmitInterceptor
{
public final String REPEAT_PARAMS = &quot;repeatParams&quot;;
public final String REPEAT_TIME = &quot;repeatTime&quot;;
// 令牌自定义标识
@Value(&quot;${token.header}&quot;)
private String header;
@Autowired
private RedisCache redisCache;
/**
* 1. nowDataMap记录请求参数和请求时间
* 2. Redis中保存信息的key=指定key + url + 消息头,value=cacheMap&lt;url,nowDataMap&gt;
* 3. 校验本次请求url的参数和之前相同url请求的参数是否相同,若相同再比较相隔时间若小于@RepeatSubmit设置的间隔时间,则表示为重复请求
*
* @param request 1
* @param annotation 2
* @return: boolean
* @author: gefeng
* @date: 2023/4/6 14:45
*/
@SuppressWarnings(&quot;unchecked&quot;)
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation)
{
String nowParams = &quot;&quot;;
if (request instanceof RepeatedlyRequestWrapper)
{
RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
nowParams = HttpHelper.getBodyString(repeatedlyRequest);
}
// body参数为空,获取Parameter的数据
if (StringUtils.isEmpty(nowParams))
{
nowParams = JSONObject.toJSONString(request.getParameterMap());
}
Map&lt;String, Object&gt; nowDataMap = new HashMap&lt;String, Object&gt;();
nowDataMap.put(REPEAT_PARAMS, nowParams);
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 取消息头做唯一值(没有消息头则使用请求地址)
String submitKey = StringUtils.trimToEmpty(request.getHeader(header));
// 唯一标识(指定key + url + 消息头)
String cacheRepeatKey = Constants.REPEAT_SUBMIT_KEY + url + submitKey;
Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
if (sessionObj != null)
{
Map&lt;String, Object&gt; sessionMap = (Map&lt;String, Object&gt;) sessionObj;
if (sessionMap.containsKey(url))
{
Map&lt;String, Object&gt; preDataMap = (Map&lt;String, Object&gt;) sessionMap.get(url);
if (compareParams(nowDataMap, preDataMap) &amp;&amp; compareTime(nowDataMap, preDataMap, annotation.interval()))
{
return true;
}
}
}
Map&lt;String, Object&gt; cacheMap = new HashMap&lt;String, Object&gt;();
cacheMap.put(url, nowDataMap);
redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);
return false;
}
/**
* 判断参数是否相同
*/
private boolean compareParams(Map&lt;String, Object&gt; nowMap, Map&lt;String, Object&gt; preMap)
{
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}
/**
* 判断两次间隔时间
*/
private boolean compareTime(Map&lt;String, Object&gt; nowMap, Map&lt;String, Object&gt; preMap, int interval)
{
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
if ((time1 - time2) &lt; interval)
{
return true;
}
return false;
}
}</code></pre>