title: Spring Security 总结 date: 2022-03-15 17:18:25 tags:
- Spring
categories: - Spring
cover: https://cover.png feature: false
1. 概要
1.1 名词概念
1.1.1 主体(principal)
使用系统的用户或设备或从其他系统远程登录的用户等等。简单说就是谁使用系统谁就是主体。
1.1.2 认证(authentication)
权限管理系统确认一个主体的身份,允许主体进入系统。简单说就是“主体”证明自己是谁。笼统的认为就是以前所做的登录操作。
1.1.3 授权(authorization)
将操作系统的“权力”“授予”“主体”,这样主体就具备了操作系统中特定功能的能力。所以简单来说,授权就是给用户分配权限。
1.2 简介
关于安全方面的两个主要区域是“认证”和“授权”(或者访问控制),一般来说,Web 应用的安全性包括用户认证(Authentication)和用户授权(Authorization) 两个部分,这两点也是 Spring Security 重要核心功能。
- 用户认证: 验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。通俗点说就是系统认为用户是否能登录
- 用户授权: 验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。通俗点讲就是系统判断用户是否有权限去做某些事情。
1.2.1 SpringSecurity 特点
- 和 Spring 无缝整合
- 全面的权限控制
- 专门为 Web 开发而设计
- 旧版本不能脱离 Web 环境使用
- 新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境
- 重量级,shiro 是轻量级的
1.2.2 模块划分
2. 过滤器
Spring Security 采用的是责任链的设计模式,它有一条很长的过滤器链(15个),只有当前过滤器通过,才能进入下一个过滤器
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.authentication.www.BasicAuthenticationFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
2.1 WebAsyncManagerIntegrationFilter
将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成
2.2 SecurityContextPersistenceFilter
在请求开始时从配置好的 SecurityContextRepository(一个仓储) 中获取该请求相关的安全上下文信息 SecurityContext,然后加载到 SecurityContextHolder 中。然后在该次请求处理完成之后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository(一个仓储),同时清除 SecurityContextHolder 所持有的 SecurityContext。
2.3 HeaderWriterFilter
向请求的 Header 中添加相应的信息,将头信息加入响应中,可在 http 标签内部使用 security:headers 来控制
2.4 CsrfFilter
用于处理跨站请求伪造,Spring Security会对 PATCH,POST,PUT 和 DELETE 方法进行防护,验证请求是否包含系统生成的 csrf 的 Token 信息,如果不包含,则报错。
2.5 LogoutFilter
默认匹配 URL 为 /logout 的请求,实现用户注销,清除认证信息。
2.6 UsernamePasswordAuthenticationFilter(重要)
进行认证操作。用于处理基于表单的登录请求,默认会拦截前端提交的 URL 为 /login 且必须为 POST 方式的登录表单请求,并进行身份认证,校验表单中用户名,密码。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的 usernameParameter 和 passwordParameter 两个参数的值进行修改。该过滤器的 doFilter() 方法实现在其抽象父类 AbstractAuthenticationProcessingFilter 中。
2.7 DefaultLoginPageGeneratingFilter
如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录认证时生成一个登录表单页面。
2.8 DefaultLogoutPageGeneratingFilter
生成默认的注销页面
2.9 BasicAuthenticationFilter
此过滤器会自动解析 HTTP 请求中头部名字为 Authentication,且以 Basic 开头的头信息,检测和处理 HTTP Basic 认证
2.10 RequestCacheAwareFilter
通过 HttpSessionRequestCache 内部维护了一个 RequestCache,用于缓存 HttpServletRequest,处理请求的缓存
2.11 SecurityContextHolderAwareRequestFilter
针对 ServletRequest 进行了—次包装,使得 request 具有更加丰富的API。
2.12 AnonymousAuthenticationFilter
当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。,检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication。
2.13 SessionManagementFilter
管理 session 的过滤器
2.14 ExceptionTranslationFilter
处理 AccessDeniedException 和 AuthenticationException 异常
2.15 FilterSecurityInterceptor
可以看做过滤器链的出口
- RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的 remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。
2.2 核心组件
2.2.1 Authentication 接口
Spring Security 的认证主体,在Spring Security 中 Authentication 用来表示当前用户是谁,可以看作 authentication 就是一组用户名密码信息。接口定义如下:
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
获取当前登录用户信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
2.2.2 SecurityContext 接口
安全上下文,用户通过 Spring Security 的校验之后,验证信息存储在 SecurityContext 中,接口定义如下:
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication var1);
}
这里只定义了两个方法,主要都是用来获取或修改认证信息(Authentication),Authentication 是用来存储着认证用户的信息,所以这个接口可以间接获取到用户的认证信息。
SecurityContext securityContext = SecurityContextHolder.getContext();
Authentication authentication = securityContext.getAuthentication();
User user = (User) authentication.getPrincipal();
2.2.3 SecurityContextHolder
在典型的 Web 应用程序中,用户登录一次,然后由其会话ID标识。服务器缓存持续时间会话的主体信息。但是在Spring Security中,在请求之间存储 SecurityContext 的责任落在 SecurityContextPersistenceFilter 上,默认情况下,该上下文将上下文存储为HTTP请求之间的 HttpSession 属性。它会为每个请求恢复上下文 SecurityContextHolder,并且最重要的是,在请求完成时清除 SecurityContextHolder。
2.2.4 AuthenticationManager
校验 Authentication,如果验证失败会抛出 AuthenticationException 异常。AuthenticationException 是一个抽象类,因此代码逻辑并不能实例化一个 AuthenticationException 异常并抛出,实际上抛出的异常通常是其实现类,如 DisabledException、LockedException、BadCredentialsException等。接口定义如下,其中可以包含多个 AuthenticationProvider:
public interface AuthenticationManager {
Authentication authenticate(Authentication var1) throws AuthenticationException;
}
2.2.5 UserDetailsService 接口
当什么也没有配置的时候,账号和密码是由 Spring Security 定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。 所以我们要通过自定义逻辑控制认证逻辑。要自定义逻辑,需要自定义一个实现类实现 UserDetailsService 接口,让 Spring Security 使用我们的 UserDetailsService 。我们自己的 UserDetailsService 可以从数据库中查询用户名和密码。
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
2.2.5.1 UserDetails
返回值 UserDetails,这个类是系统默认的用户“主体”
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new UserDetails() {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
};
}
2.2.5.2 User
UserDetails 实现类: 可以使用 User 这个实现类返回用户名、密码和权限。 方法参数 username,表示用户名。此值是客户端表单传递过来的数据。默认情况下必须叫 username,否则无法接收。
2.2.6 PasswordEncoder 接口
package org.springframework.security.crypto.password;
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
接口实现类:
- BCryptPasswordEncoder 是 Spring Security 官方推荐的密码解析器,平时常使用这个解析器。
- BCryptPasswordEncoder 是对 bcrypt 强散列方法的具体实现。是基于 Hash 算法实现的单向加密。可以通过 strength 控制加密强度,默认 10。
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String fan = bCryptPasswordEncoder.encode("fan");
System.out.println("加密之后数据:\t" + fan);
boolean result = bCryptPasswordEncoder.matches("fan", fan);
System.out.println("比较结果:\t"+result);
- ProviderManager 对象为 AuthenticationManager 接口的实现类
- AuthenticationProvider:要用来进行认证操作的类,调用其中的 authenticate() 方法去进行认证操作
- GrantedAuthority:对认证主题的应用层面的授权,含当前用户的权限信息,通常使用角色表示
- UserDetails:构建 Authentication 对象必须的信息,可以自定义,可能需要访问DB得到
- UserDetailsService:通过 username 构建 UserDetails 对象,通过 loadUserByUsername() 方法根据username 获取 UserDetail 对象
2.3 工作流程
2.3.1 登录校验流程
2.3.3 授权流程
2.4 重要过滤器(认证流程和授权流程)
2.4.2 UsernamePasswordAuthenticationFilter(认证流程)
该过滤器会拦截前端提交的 POST 方式的登录表单请求,并进行身份认证,校验表单中用户名,密码。该过滤器的 doFilter() 方法实现在其抽象父类 AbstractAuthenticationProcessingFilter 中:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Request is to process authentication");
}
Authentication authResult;
try {
authResult = attemptAuthentication(request, response);
if (authResult == null) {
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {
logger.error(
"An internal error occurred while trying to authenticate the user.",
failed);
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
unsuccessfulAuthentication(request, response, failed);
return;
}
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authResult);
}
上面第二步调用了 UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 方法:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
上面第三步创建的 UsernamePasswordAuthenticationToken 是 Authentication 接口的实现类,该类有两个构造器,一个用于封装前端请求传入的未认证的用户信息,一个用于封装认证成功后的用户信息:
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
}
上面 UsernamePasswordAuthenticationFilter 过滤器的attemptAuthentication() 方法的第五步将未认证的 Authentication 对象传入 ProviderManager 类的 authenticate() 方法进行身份认证。
ProviderManager 是 AuthenticationManager 接口的实现类,该接口是认证相关的核心接口,也是认证的入口。在实际开发中,我们可能有多种不同的认证方式,例如:用户名+密码、邮箱+密码、手机号+验证码等,而这些认证方式的入口始终只有一个,那就是 AuthenticationManager。在该接口的常用实现类 ProviderManager 内部会维护一个 List<AuthenticationProvider> 列表,存放多种认证方式,实际上这是委托者模式(Delegate)的应用。每种认证方式对应着一个 AuthenticationProvider,AuthenticationManager 根据认证方式的不同(根据传入的 Authentication 类型判断)委托对应的 AuthenticationProvider 进行用户认证。
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && parent != null) {
try {
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
}
catch (AuthenticationException e) {
lastException = parentException = e;
}
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
((CredentialsContainer) result).eraseCredentials();
}
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
}
上面认证成功之后的第六步,调用 CredentialsContainer 接口定义的 eraseCredentials() 方法去除敏感信息。查看 UsernamePasswordAuthenticationToken 实现的 eraseCredentials() 方法,该方法实现在其父类中:
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
public void eraseCredentials() {
eraseSecret(getCredentials());
eraseSecret(getPrincipal());
eraseSecret(details);
}
private void eraseSecret(Object secret) {
if (secret instanceof CredentialsContainer) {
((CredentialsContainer) secret).eraseCredentials();
}
}
}
上述过程就是认证流程的最核心部分,接下来重新回到 UsernamePasswordAuthenticationFilter 过滤器的 doFilter() 方法,查看认证成功/失败的处理:
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
successHandler.onAuthenticationSuccess(request, response, authResult);
}
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
if (logger.isDebugEnabled()) {
logger.debug("Authentication request failed: " + failed.toString(), failed);
logger.debug("Updated SecurityContextHolder to contain null Authentication");
logger.debug("Delegating to authentication failure handler " + failureHandler);
}
rememberMeServices.loginFail(request, response);
failureHandler.onAuthenticationFailure(request, response, failed);
}
2.4.3 ExceptionTranslationFilter(授权)
是个异常过滤器,该过滤器不需要我们配置,对于前端提交的请求会直接放行,捕获后续抛出的异常并进行处理(例如:权限访问限制)。
public class ExceptionTranslationFilter extends GenericFilterBean {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
chain.doFilter(request, response);
logger.debug("Chain processed normally");
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
RuntimeException ase = (AuthenticationException) throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (ase == null) {
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (ase != null) {
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, ase);
}
else {
if (ex instanceof ServletException) {
throw (ServletException) ex;
}
else if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
throw new RuntimeException(ex);
}
}
}
}
2.4.4 FilterSecurityInterceptor(授权)
该过滤器是过滤器链的最后一个过滤器,前面解决了认证问题,接下来是是否可访问指定资源的问题,FilterSecurityInterceptor 用了 AccessDecisionManager 来进行鉴权。根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,最终所抛出的异常会由前一个过滤器 ExceptionTranslationFilter 过滤器进行捕获和处理。
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
}
Spring Security 的过滤器链是配置在 SpringMVC 的核心组件 DispatcherServlet 运行之前。也就是说,请求通过 Spring Security 的所有过滤器,不意味着能够正常访问资源,该请求还需要通过 SpringMVC 的拦截器链。
2.7 请求间共享认证信息
- 查看 SecurityContext 接口及其实现类 SecurityContextImpl , 该类其实就是 对Authentication 的封装
- 查看 SecurityContextHolder 类 , 该类其实是对 ThreadLocal 的封装 , 存储 SecurityContext 对象
SecurityContextPersistenceFilter 过滤器
在 UsernamePasswordAuthenticationFilter 过滤器认证成功之后,会在认证成功的处理方法中将已认证的用户信息对象 Authentication 封装进 SecurityContext,并存入 SecurityContextHolder。之后,响应会通过 SecurityContextPersistenceFilter 过滤器,该过滤器的位置在所有过滤器的最前面,请求到来先进它,响应返回最后一个通过它,所以在该过滤器中处理已认证的用户信息对象 Authentication 与 Session 绑定。
认证成功的响应通过 SecurityContextPersistenceFilter 过滤器时,会从 SecurityContextHolder 中取出封装了已认证用户信息对象 Authentication 的 SecurityContext,放进 Session 中。当请求再次到来时,请求首先经过该过滤器,该过滤器会判断当前请求的 Session 是否存有 SecurityContext 对象,如果有则将该对象取出再次放入 SecurityContextHolder 中,之后该请求所在的线程获得认证用户信息,后续的资源访问不需要进行身份认证;当响应再次返回时,该过滤器同样从 SecurityContextHolder 取出 SecurityContext 对象,放入 Session 中。
3. SpringSecurity Web 权限
Spring Security 的核心配置类是 WebSecurityConfigurerAdapter 抽象类,这是权限管理启动的入口。
3.1 设置登录系统的账号、密码
3.1.1 YML
spring:
security:
user:
name: fan
password: fan
3.1.2 配置基于内存的角色授权和认证信息
继承 WebSecurityConfigurerAdapter,重写 configure(AuthenticationManagerBuilder auth) 方法
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encode = bCryptPasswordEncoder.encode("fan223");
auth.inMemoryAuthentication().withUser("fan223").password(encode).roles("admin");
}
@Bean
PasswordEncoder passwordEncoder(){
return new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return Objects.equals(charSequence.toString(), s);
}
};
}
}
3.1.3 配置基于数据库的认证信息和角色授权
1、编写实现类,实现 UserDetailsService 接口,实现其 UserDetails loadUserByUsername(String username) 方法,返回一个 UserDetails 接口的实现类 User 对象,包括用户名、密码、权限。 2、配置类里将实现类注入进入
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsService myUserDetailsServiceImpl;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
}
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserDAO userDAO;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
fan.springsecuritytest.entity.User selectOne = userDAO.selectOne(new QueryWrapper<fan.springsecuritytest.entity.User>().eq("username", username));
if (ObjectUtils.isEmpty(selectOne)){
throw new UsernameNotFoundException("用户名不存在!");
}
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role, ROLE_sale");
return new User(selectOne.getUsername(), new BCryptPasswordEncoder().encode(selectOne.getPassword()), authorities);
}
}
3.2 自定义表单认证登录(重要)
3.2.1 配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsService myUserDetailsServiceImpl;
@Resource
private AuthenticationSuccessHandler loginSuccessHandler;
@Resource
private AuthenticationFailureHandler loginFailureHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
}
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/", "/hello", "/login").permitAll()
.antMatchers("/web/admin/**").hasAnyAuthority("admin")
.anyRequest()
.authenticated(); // 都需要认证
.and()
// 2. 配置登录表单认证方式
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/user/login")
.usernameParameter("uname")
.passwordParameter("pword")
.successForwardUrl("/success")
.failureForwardUrl("/error")
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(new CustomLogoutSuccessHandler())
.logoutSuccessUrl("/login")
.clearAuthentication(true)
.invalidateHttpSession(true)
.permitAll()
.and()
.sessionManagement()
.invalidSessionUrl("/login")
http.headers().frameOptions().disable();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/**");
}
}
3.2.2 配置静态资源
仅仅通过 Spring Security 配置是不够的,还需要去重写 addResourceHandlers 方法去映射静态资源。写一个类 WebMvcConfig 继承 WebMvcConfigurationSupport。
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
super.addResourceHandlers(registry);
}
}
3.2.3 配置错误页面(非必要)
可以不进行配置,只需对应错误页面放在 /error 文件夹下即可
@Configuration
public class ErrorPageConfig {
@Bean
public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer() {
WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer = new WebServerFactoryCustomizer<ConfigurableWebServerFactory>() {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage[] errorPages = new ErrorPage[] {
new ErrorPage(HttpStatus.FORBIDDEN, "/403"),
new ErrorPage(HttpStatus.NOT_FOUND, "/404"),
new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/500"),
};
factory.addErrorPages(errorPages);
}
};
return webServerFactoryCustomizer;
}
}
3.2.4 Controller
@Controller
public class TestController {
@GetMapping("/login")
public String login(){
return "login";
}
@GetMapping("/error")
public String error(){
return "error";
}
@GetMapping("/success")
public String success(){
return "success";
}
@GetMapping("/hello")
public String hello(){
return "/hello";
}
@GetMapping("/test")
@ResponseBody
public String test(){
return "test 请求";
}
}
3.2.5 自定义权限决策管理器
@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
for (ConfigAttribute configAttribute : configAttributes) {
if (authentication == null){
throw new AccessDeniedException("当前访问没有权限");
}
String needRole = configAttribute.getAttribute();
if ("ROLE_LOGIN".equals(needRole)){
if (authentication instanceof AnonymousAuthenticationToken){
throw new BadCredentialsException("未登录");
}
return;
}
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(needRole)){
return;
}
}
}
throw new AccessDeniedException("权限不足");
}
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public boolean supports(Class<?> clazz) {
return true;
}
}
3.2.6 登录认证成功处理器
在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果登录成功了会调用 AuthenticationSuccessHandler 的方法进行认证成功后的处理。AuthenticationSuccessHandler 就是登录成功处理器。
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Resource
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String paraName = parameterNames.nextElement();
System.out.println("参数- " + paraName + " : " + request.getParameter(paraName));
}
try {
this.getRedirectStrategy().sendRedirect(request, response, "/success");
} catch (JsonProcessingException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.2.7 登录认证失败处理器
在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果认证失败了会调用 AuthenticationFailureHandler 的方法进行认证失败后的处理。AuthenticationFailureHandler 就是登录失败处理器。
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Resource
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) {
this.saveException(request, exception);
try {
this.getRedirectStrategy().sendRedirect(request, response, "/login");
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.2.8 注销成功处理器
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse response, Authentication authentication) {
response.setStatus(HttpStatus.OK.value());
response.setContentType("application/json;charset=UTF-8");
try {
response.getWriter().write("注销成功!");
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.2.9 登录页面的 Form 表单
<form action="/user/login" method="post">
用户名:<input type="text" name="uname"/><br/>
密码:<input type="password" name="pword"/><br/>
<input type="submit" value="提交"/>
</form>
3.3 基于角色或权限进行访问控制
3.3.1 hasAuthority() 方法
如果当前的主体具有指定的权限,则返回 true,否则返回 false
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/hello", "/login").permitAll()
.antMatchers("/test").hasAuthority("admin")
.anyRequest().authenticated();
}
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserDAO userDAO;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
fan.springsecuritytest.entity.User selectOne = userDAO.selectOne(new QueryWrapper<fan.springsecuritytest.entity.User>().eq("username", username));
if (ObjectUtils.isEmpty(selectOne)){
throw new UsernameNotFoundException("用户名不存在!");
}
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role");
return new User(selectOne.getUsername(), new BCryptPasswordEncoder().encode(selectOne.getPassword()), authorities);
}
}
无权限访问, Forbidden 403
3.3.2 hasAnyAuthority() 方法
如果当前的主体有任何提供的角色(给定的作为一个逗号分隔的字符串列表)的话,返回 true。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/hello", "/login").permitAll()
.antMatchers("/test").hasAnyAuthority("admin", "role")
.anyRequest().authenticated();
}
3.3.3 hasRole() 方法
如果用户具备给定角色就允许访问,否则出现 403。如果当前主体具有指定的角色,则返回 true。 由于底层源码给设定的 role 加上了前缀 “ROLE_”,所以给主体设定角色时,也要加上前缀
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/hello", "/login").permitAll()
.antMatchers("/test").hasRole("sale")
.anyRequest().authenticated();
}
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserDAO userDAO;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
fan.springsecuritytest.entity.User selectOne = userDAO.selectOne(new QueryWrapper<fan.springsecuritytest.entity.User>().eq("username", username));
if (ObjectUtils.isEmpty(selectOne)){
throw new UsernameNotFoundException("用户名不存在!");
}
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role, ROLE_sale");
return new User(selectOne.getUsername(), new BCryptPasswordEncoder().encode(selectOne.getPassword()), authorities);
}
}
3.3.4 hasAnyRole() 方法
表示用户具备任何一个角色都可以访问
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/hello", "/login").permitAll()
.antMatchers("/test").hasAnyRole("sale", "sale1")
.anyRequest().authenticated();
}
3.3.5 自定义没有权限访问页面(非必要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling().accessDeniedPage("/forbidden");
}
3.4 注解使用
3.4.1 @Secured
开启注解
@EnableGlobalMethodSecurity(securedEnabled=true)
可以加在启动类上,也可以在配置类上 @Secured 判断是否具有角色,只有具有该角色才可以进行访问,这里匹配的字符串需要添加前缀 “ROLE_“
@GetMapping("/demo01")
@ResponseBody
@Secured(value = {"ROLE_sale", "ROLE_sale1"})
public String demo01(){
return "demo01 请求";
}
3.4.2 @PreAuthorize
进入方法前的权限验证, @PreAuthorize 可以将登录用户的 roles/permissions 参数传到方法中
@EnableGlobalMethodSecurity(prePostEnabled = true)
@GetMapping("/demo01")
@ResponseBody
@PreAuthorize("hasAnyAuthority('role')")
public String demo01(){
return "demo01 请求";
}
3.4.3 @PostAuthorize
@EnableGlobalMethodSecurity(prePostEnabled = true)
@PostAuthorize 注解使用并不多,在方法执行后再进行权限验证,适合验证带有返回值的权限
@GetMapping("/demo02")
@ResponseBody
@PostAuthorize("hasAnyAuthority('admin')")
public String demo02(){
System.out.println("返回前执行的方法!");
return "demo02 请求";
}
3.4.4 PreFilter
进入控制器之前对数据进行过滤,假如值取模 2 为 0,则输出
@RequestMapping("getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理员')")
@PreFilter(value = "filterObject.id%2==0")
@ResponseBody
public List<UserInfo> getTestPreFilter(@RequestBody List<UserInfo> list){
list.forEach(t -> {
System.out.println(t.getId() + "\t" + t.getUsername());
});
return list;
}
3.4.5 @PostFilter
权限验证之后对数据进行过滤 留下用户名是 admin1 的数据,表达式中的 filterObject 引用的是方法返回值 List 中的某一个元素
@GetMapping("/demo01")
@PreAuthorize("hasRole('ROLE_sale')")
@PostFilter("filterObject.username == 'admin1'")
@ResponseBody
public List<UserInfo> getAllUser(){
ArrayList<UserInfo> list = new ArrayList<>();
list.add(new UserInfo(1l,"admin1","6666"));
list.add(new UserInfo(2l,"admin2","888"));
return list;
}
3.5 基于数据库的记住我
3.5.1 SQL
jdbcTokenRepository.setCreateTableOnStartup(true);
该语句会自动在数据库中创建一个存放 Token 及相关信息的一个表,表名为 persistent_logins,也可以手动创建该表,不执行该语句。只有数据库中不存在该表需要创建表才执行。
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
3.5.2 添加记住我功能
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsService myUserDetailsServiceImpl;
@Resource
private DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
}
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.rememberMe()
.userDetailsService(myUserDetailsServiceImpl)
.tokenRepository(persistentTokenRepository());
}
}
3.5.3 登录页面
<form action="/user/login" method="post">
用户名:<input type="text" name="uname"/><br/>
密码:<input type="password" name="pword"/><br/>
<input type="submit" value="提交"/>
记住我:<input type="checkbox" name="remember-me" title="记住密码"/><br/>
</form>
3.5.4 设置有效期
@Override
protected void configure(HttpSecurity http) throws Exception {
http.rememberMe()
.userDetailsService(myUserDetailsServiceImpl)
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(100);
}
3.6 用户注销
3.6.1 退出链接
<a href="/logout">退出</a>
3.6.2 配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailsService myUserDetailsServiceImpl;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
}
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login").permitAll();
}
}
3.7 CSRF
3.7.1 概念
跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。
跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了 web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
从 Spring Security 4.0 开始,默认情况下会启用 CSRF 保护,以防止 CSRF 攻击应用程序,Spring Security CSRF 会针对 PATCH,POST,PUT 和 DELETE 方法进行防护。
3.7.2 Spring Security 防御机制
3.7.2.1 Csrf Token
用户登录时,系统发放一个 CsrfToken 值,用户携带该 CsrfToken 值与用户名、密码等参数完成登录。系统记录该会话的 CsrfToken 值,之后在用户的任何请求中,都必须带上该 CsrfToken 值,并由系统进行校验。
3.7.2.2 SpringSecurity 中使用 Csrf Token
Spring Security 通过注册一个 CsrfFilter 来专门处理 CSRF 攻击,在 Spring Security 中,CsrfToken 是一个用于描述 Token 值,以及验证时应当获取哪个请求参数或请求头字段的接口。
public interface CsrfToken extends Serializable {
String getHeaderName();
String getParameterName();
String getToken();
}
public interface CsrfTokenRepository {
CsrfToken generateToken(HttpServletRequest request);
void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);
CsrfToken loadToken(HttpServletRequest request);
}
实现类: 默认使用的是 DefaultCsrfToken 默认使用的是 HttpSessionCsrfTokenRepository
3.7.2.3 HttpSessionCsrfTokenRepository
在默认情况下,Spring Security 加载的是一个HttpSessionCsrfTokenRepository,将 CsrfToken 值存储在 HttpSession 中,并指定前端把 CsrfToken 值放在名为 “_csrf” 的请求参数或名为 “X-CSRF-TOKEN” 的请求头字段里(可以调用相应的设置方法来重新设定)。校验时,通过对比 HttpSession 内存储的 CsrfToken 值与前端携带的 CsrfToken 值是否一致,便能断定本次请求是否为 CSRF 攻击。
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN");
private String parameterName = "_csrf";
private String headerName = "X-CSRF-TOKEN";
private String sessionAttributeName;
public HttpSessionCsrfTokenRepository() {
this.sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;
}
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
HttpSession session;
if (token == null) {
session = request.getSession(false);
if (session != null) {
session.removeAttribute(this.sessionAttributeName);
}
} else {
session = request.getSession();
session.setAttribute(this.sessionAttributeName, token);
}
}
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session == null ? null : (CsrfToken)session.getAttribute(this.sessionAttributeName);
}
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName, this.createNewToken());
}
public void setParameterName(String parameterName) {
Assert.hasLength(parameterName, "parameterName cannot be null or empty");
this.parameterName = parameterName;
}
public void setHeaderName(String headerName) {
Assert.hasLength(headerName, "headerName cannot be null or empty");
this.headerName = headerName;
}
public void setSessionAttributeName(String sessionAttributeName) {
Assert.hasLength(sessionAttributeName, "sessionAttributename cannot be null or empty");
this.sessionAttributeName = sessionAttributeName;
}
private String createNewToken() {
return UUID.randomUUID().toString();
}
}
- saveToken 方法将 CsrfToken 保存在 HttpSession 中,将来再从 HttpSession 中取出和前端传来的参数做比较。
- loadToken 方法当然就是从 HttpSession 中读取 CsrfToken 出来。
- generateToken 是生成 CsrfToken 的过程,可以看到,生成的默认载体就是 DefaultCsrfToken,而 CsrfToken 的值则通过 createNewToken 方法生成,是一个 UUID 字符串。
- 在构造 DefaultCsrfToken 是还有两个参数 headerName 和 parameterName,这两个参数是前端保存参数的 key。
适用于前后端不分离的开发
<input type="hidden" th:if="${_csrf}!=null" th:value="${_csrf.token}" name="_csrf"/>
或者<input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}">
3.7.2.4 CookieCsrfTokenRepository
前后端分离开发需要 CsrfTokenRepository 的另一个实现类 CookieCsrfTokenRepository ,是一种更加灵活可行的方案,它将 CsrfToken 值存储在用户的 cookie 内。减少了服务器 HttpSession 存储的内存消耗,并且当用 cookie 存储 CsrfToken 值时,前端可以用 JavaScript 读取(需要设置该 cookie 的 httpOnly 属性为 false),而不需要服务器注入参数,在使用方式上更加灵活。
存储在 cookie 中是不会被 CSRF 利用的,cookie 只有在同域的情况下才能被读取,所以杜绝了第三方站点跨域获取 CsrfToken 值的可能。同时 CSRF 攻击本身是不知道 cookie 内容的,只是利用了当请求自动携带 cookie 时可以通过身份验证的漏洞。但服务器对 CsrfToken 值的校验并非取自 cookie,而是需要前端从 Cookie 中自己提取出来 _csrf 参数,然后拼接成参数传递给后端,单纯的将 Cookie 中的数据传到服务端是没用的。
-
配置的时候通过 withHttpOnlyFalse 方法获取 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 JS 操作 Cookie(否则就没有办法获取到 _csrf)。 @Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
-
可以采用 header 或者 param 的方式添加 csrf_token,下面示范从 cookie 中获取 token <form action="/executeLogin" method="post">
<input type="hidden" name="_csrf">
用户名<input type="text" name="username" class="lowin-input">
密码<input type="password" name="password" class="lowin-input">
记住我<input name="remember-me" type="checkbox" value="true" />
<input class="lowin-btn login-btn" type="submit">
</form>
<script>
$(function () {
var aCookie = document.cookie.split("; ");
console.log(aCookie);
for (var i=0; i < aCookie.length; i++){
var aCrumb = aCookie[i].split("=");
if ("XSRF-TOKEN" == aCrumb[0])
$("input[name='_csrf']").val(aCrumb[1]);
}
});
</script>
3.8.2.5 LazyCsrfTokenRepository
对于常见的 GET 请求实际上是不需要 CSRF 攻击校验的,但是,每当 GET 请求到来时,下面这段代码都会执行:
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
生成 CsrfToken 并保存,但实际上却没什么用,因为 GET 请求不需要 CSRF 攻击校验。所以,Spring Security 官方又推出了 LazyCsrfTokenRepository。
LazyCsrfTokenRepository 实际上不能算是一个真正的 CsrfTokenRepository,它是一个代理,可以用来增强 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的功能:
public final class LazyCsrfTokenRepository implements CsrfTokenRepository {
private static final String HTTP_RESPONSE_ATTR = HttpServletResponse.class.getName();
private final CsrfTokenRepository delegate;
public LazyCsrfTokenRepository(CsrfTokenRepository delegate) {
Assert.notNull(delegate, "delegate cannot be null");
this.delegate = delegate;
}
public CsrfToken generateToken(HttpServletRequest request) {
return this.wrap(request, this.delegate.generateToken(request));
}
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
if (token == null) {
this.delegate.saveToken(token, request, response);
}
}
public CsrfToken loadToken(HttpServletRequest request) {
return this.delegate.loadToken(request);
}
private CsrfToken wrap(HttpServletRequest request, CsrfToken token) {
HttpServletResponse response = this.getResponse(request);
return new LazyCsrfTokenRepository.SaveOnAccessCsrfToken(this.delegate, request, response, token);
}
private HttpServletResponse getResponse(HttpServletRequest request) {
HttpServletResponse response = (HttpServletResponse)request.getAttribute(HTTP_RESPONSE_ATTR);
Assert.notNull(response, () -> {
return "The HttpServletRequest attribute must contain an HttpServletResponse for the attribute " + HTTP_RESPONSE_ATTR;
});
return response;
}
private static final class SaveOnAccessCsrfToken implements CsrfToken {
private transient CsrfTokenRepository tokenRepository;
private transient HttpServletRequest request;
private transient HttpServletResponse response;
private final CsrfToken delegate;
SaveOnAccessCsrfToken(CsrfTokenRepository tokenRepository, HttpServletRequest request, HttpServletResponse response, CsrfToken delegate) {
this.tokenRepository = tokenRepository;
this.request = request;
this.response = response;
this.delegate = delegate;
}
public String getHeaderName() {
return this.delegate.getHeaderName();
}
public String getParameterName() {
return this.delegate.getParameterName();
}
public String getToken() {
this.saveTokenIfNecessary();
return this.delegate.getToken();
}
public boolean equals(Object obj) {
if (this == obj) {
return true;
} else if (obj != null && this.getClass() == obj.getClass()) {
LazyCsrfTokenRepository.SaveOnAccessCsrfToken other = (LazyCsrfTokenRepository.SaveOnAccessCsrfToken)obj;
if (this.delegate == null) {
if (other.delegate != null) {
return false;
}
} else if (!this.delegate.equals(other.delegate)) {
return false;
}
return true;
} else {
return false;
}
}
public int hashCode() {
int prime = true;
int result = 1;
int result = 31 * result + (this.delegate == null ? 0 : this.delegate.hashCode());
return result;
}
public String toString() {
return "SaveOnAccessCsrfToken [delegate=" + this.delegate + "]";
}
private void saveTokenIfNecessary() {
if (this.tokenRepository != null) {
synchronized(this) {
if (this.tokenRepository != null) {
this.tokenRepository.saveToken(this.delegate, this.request, this.response);
this.tokenRepository = null;
this.request = null;
this.response = null;
}
}
}
}
}
}
- generateToken 方法,该方法用来生成 CsrfToken,默认 CsrfToken 的载体是 DefaultCsrfToken,现在换成了 SaveOnAccessCsrfToken。
- SaveOnAccessCsrfToken 和 DefaultCsrfToken 并没有太大区别,主要是 getToken 方法有区别,在 SaveOnAccessCsrfToken 中,当开发者调用 getToken 想要去获取 csrfToken 时,才会去对 csrfToken 做保存操作(调用 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的 saveToken 方法)。
- LazyCsrfTokenRepository 自己的 saveToken 则做了修改,相当于放弃了 saveToken 的功能,调用该方法并不会做保存操作。
在使用 Spring Security 时,如果对 csrf 不做任何配置,默认其实就是 LazyCsrfTokenRepository + HttpSessionCsrfTokenRepository 组合
3.7.3 参数校验
校验主要是通过 CsrfFilter 过滤器来进行,核心为 doFilterInternal() 方法
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = csrfToken == null;
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match " + this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
} else {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(LogMessage.of(() -> {
return "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request);
}));
AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
filterChain.doFilter(request, response);
}
}
}
- 首先调用 tokenRepository.loadToken 方法读取 CsrfToken 出来,这个 tokenRepository 就是配置的 CsrfTokenRepository 实例,CsrfToken 存在 HttpSession 中,这里就从 HttpSession 中读取,CsrfToken 存在 Cookie 中,这里就从 Cookie 中读取。
- 如果调用 tokenRepository.loadToken 方法没有加载到 CsrfToken,那说明这个请求可能是第一次发起,则调用 tokenRepository.generateToken 方法生成 CsrfToken ,并调用 tokenRepository.saveToken 方法保存 CsrfToken。
- 这里还调用 request.setAttribute 方法存了一些值进去,这就是默认情况下,通过 JSP 或者 Thymeleaf 标签渲染 _csrf 的数据来源
- requireCsrfProtectionMatcher.matches() 方法则使用用来判断哪些请求方法需要做校验,默认情况下,“GET”, “HEAD”, “TRACE”, “OPTIONS” 方法是不需要校验的。
- 接下来获取请求中传递来的 CSRF 参数,先从请求头中获取,获取不到再从请求参数中获取
- 获取到请求传来的 CSRF 参数之后,再和一开始加载到的 csrfToken 做比较,如果不同的话,就抛出异常
3.7.4 CSRF 注销
开启 CSRF 后,不仅登录受到保护,注销也同样受到保护,因此同样需要带上 CsrfToken。
<form action="/logout" method="post">
<input type="hidden" th:value="${_csrf.token}" name="_csrf"/>
<input type="submit" value="退出">
</form>
4. SpringSecurity 微服务权限方案
4.1 概念
微服务,关键其实不仅仅是微服务本身,而是系统要提供一套基础的架构,这种架构使得微服务可以独立的部署、运行、升级,不仅如此,这个系统架构还让微服务与微服务之间在结构上“松耦合”,而在功能上则表现为一个统一的整体。这种所谓的“统一的整体”表现出来的是统一风格的界面,统一的权限管理,统一的安全策略,统一的上线过程,统一的日志和审计方法,统一的调度方式,统一的访问入口等等。微服务的目的是有效的拆分应用,实现敏捷开发和部署。
4.2 微服务认证与授权实现
4.2.1 认证授权过程分析
- 如果是基于 Session,那么 Spring-security 会对 cookie 里的 sessionid 进行解析,找到服务器存储的 session 信息,然后判断当前用户是否符合请求的要求。
- 如果是 token,则是解析出 token,然后将当前请求加入到 Spring Security 管理的权限信息中去
如果系统的模块众多,每个模块都需要进行授权与认证,所以选择基于 token 的形式进行授权与认证,用户根据用户名密码认证成功,然后获取当前用户角色的一系列权限值,并以用户名为 key,权限列表为 value 的形式存入 redis 缓存中,根据用户名相关信息生成 token 返回,浏览器将 token 记录到 cookie 中,每次调用 api 接口都默认将 token 携带到 header 请求头中,Spring-security 解析 header 头获取 token 信息,解析 token 获取当前用户名,根据用户名就可以从 redis 中获取权限列表,这样 Spring-security 就能够判断当前请求是否有权限访问。
4.2.2 权限管理数据模型
4.2.4 具体实现
4.2.4.1 实体类
@Data
@ApiModel(description = "用户实体类")
public class User implements Serializable {
private String username;
private String password;
}
@Data
@ApiModel(description = "角色实体类")
public class Role implements Serializable {
private String roleName;
private String roleCode;
}
@Data
@ApiModel(value="Permission对象", description="权限实体类")
public class Permission implements Serializable {
@ApiModelProperty(value = "所属上级")
private String pid;
@ApiModelProperty(value = "名称")
private String name;
@ApiModelProperty(value = "类型(1:菜单,2:按钮)")
private Integer type;
@ApiModelProperty(value = "权限值")
private String permissionValue;
@ApiModelProperty(value = "访问路径")
private String path;
@ApiModelProperty(value = "状态(0:禁止,1:正常)")
private Integer status;
@ApiModelProperty(value = "层级")
@TableField(exist = false)
private Integer level;
@ApiModelProperty(value = "下级")
@TableField(exist = false)
private List<Permission> children;
@ApiModelProperty(value = "是否选中")
@TableField(exist = false)
private boolean isSelect;
}
@Data
@NoArgsConstructor
@ApiModel(description = "SecurityUser实体类,UserDetailsService 接口的返回对象")
public class SecurityUser implements UserDetails {
private transient User currentUser;
private List<String> permissionValueList;
public SecurityUser(User user) {
if (user != null) {
this.currentUserInfo = user;
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) continue;
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
return authorities;
}
@Override
public String getPassword() {
return currentUser.getPassword();
}
@Override
public String getUsername() {
return currentUser.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
4.2.4.2 TokenLoginFilter 认证
进行认证的 Filter,通过 AuthenticationManager 的 authenticate 方法来进行用户认证,所以需要把 AuthenticationManager 注入容器或者直接传入 authenticationManager() 方法
http.addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate));
认证成功的话生成一个 JWT,放入响应中返回。并且为了让用户下回请求时能通过 JWT 识别出具体的是哪个用户,需要把用户相关信息存入 Redis。
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
private AuthenticationManager authenticationManager;
public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
this.authenticationManager = authenticationManager;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.setPostOnly(false);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
Authentication authenticate = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));
return authenticate;
} catch (IOException e) {
e.printStackTrace(); throw new RuntimeException();
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SecurityUser user = (SecurityUser)authResult.getPrincipal();
String jwt = tokenManager.createToken(user.getCurrentUser().getUsername());
redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList());
ResponseUtil.out(response, R.ok().data("Token", jwt));
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}
4.2.4.3 UserDetailsServiceImpl
实现 UserDetailsService 接口,根据用户名从数据库查出用户信息和用户信息和用户权限信息。
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserService userService;
@Autowired
private PermissionService permissionService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.selectByUsername(username);
if(user == null) {
throw new UsernameNotFoundException("用户不存在");
}
List<String> permissionValueList = permissionService.selectPermissionValueByUserId(user.getId());
SecurityUser securityUser = new SecurityUser();
securityUser.setCurrentUser(user);
securityUser.setPermissionValueList(permissionValueList);
return securityUser;
}
}
4.2.4.4 TokenManager
操作 Token 的工具类
@Component
public class TokenManager {
private long tokenEcpiration = 24*60*60*1000;
private String tokenSignKey = "123456";
public String createToken(String username) {
String token = Jwts.builder().setSubject(username)
.setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY).compact();
return token;
}
public String getUserInfoFromToken(String token) {
String userInfo = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody().getSubject();
return userInfo;
}
public void removeToken(String token) { }
}
4.2.4.5 TokenAuthFilter 请求拦截
进行授权的 Filter,过滤器会去获取请求头中的 Token,对 Token 进行解析取出其中的 username。使用 username 去 Redis 中获取对应的 权限列表。然后封装 Authentication 对象存 SecurityContextHolder。
在 Spring Security中,会使用默认的 FilterSecurityInterceptor 来进行权限校验。在 FilterSecurityInterceptor 中会从 SecurityContextHolder 获取其中的 Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。在项目中只需要把当前登录用户的权限信息也存入Authentication。然后设置资源所需要的权限即可。
@AllArgsConstructor
public class TokenAuthFilter extends OncePerRequestFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
UsernamePasswordAuthenticationToken authRequest = getAuthentication(request);
if(authRequest != null) {
SecurityContextHolder.getContext().setAuthentication(authRequest);
}
chain.doFilter(request,response);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
String token = request.getHeader("token");
if(token != null) {
String username = tokenManager.getUserInfoFromToken(token);
List<String> permissionValueList = (List<String>)redisTemplate.opsForValue().get(username);
Collection<GrantedAuthority> authority = new ArrayList<>();
for(String permissionValue : permissionValueList) {
SimpleGrantedAuthority auth = new SimpleGrantedAuthority(permissionValue);
authority.add(auth);
}
return new UsernamePasswordAuthenticationToken(username, token, authority);
}
return null;
}
}
4.2.4.6 DefaultPasswordEncoder 加密
加密处理
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence charSequence) {
return MD5.encrypt(charSequence.toString());
}
@Override
public boolean matches(CharSequence charSequence, String encodedPassword) {
return encodedPassword.equals(MD5.encrypt(charSequence.toString()));
}
}
4.2.4.7 CustomerLogoutHandler 注销
可以和注销成功处理器一起使用,LogoutHandler --> LogoutSuccessHandler
@AllArgsConstructor
public class CustomerLogoutHandler implements LogoutHandler {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String token = request.getHeader("token");
if(token != null) {
tokenManager.removeToken(token);
String username = tokenManager.getUserInfoFromToken(token);
redisTemplate.delete(username);
}
ResponseUtil.out(response, R.ok());
}
}
4.2.4.8 UnAuthorizedEntryPoint 和 UnAccessDeniedHandler 异常处理
在认证失败或者是授权失败的情况下返回异常处理
在 Spring Security 中,如果在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter 捕获到。在 ExceptionTranslationFilter 中会去判断是认证失败还是授权失败出现的异常。
- 如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用 AuthenticationEntryPoint 对象的方法去进行异常处理。
- 如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用 AccessDeniedHandler 对象的方法去进行异常处理。
所以如果需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint 和 AccessDeniedHandler 然后配置给 Spring Security 即可。
public class UnAuthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(httpServletResponse, R.error(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录"));
}
}
@Component
public class UnAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ResponseUtil.out(httpServletResponse, R.error(HttpStatus.FORBIDDEN.value(), "权限不足"));
}
}
4.2.4.9 配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@AllArgsConstructor
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
private DefaultPasswordEncoder defaultPasswordEncoder;
private UserDetailsService userDetailsServiceImpl;
private UnAccessDeniedHandler unAccessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new UnauthEntryPoint())
.accessDeniedHandler(unAccessDeniedHandler)
.and().csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and().logout().logoutUrl("/admin/acl/index/logout")
.addLogoutHandler(new CustomerLogoutHandler(tokenManager,redisTemplate))
.logoutSuccessHandler(new CustomerCLogoutSuccessHandler()).and()
.addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate))
.addFilterBefore(new TokenAuthFilter(tokenManager, redisTemplate), UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(defaultPasswordEncoder);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/api/**");
}
}
4.2.4.10 Controller
@GetMapping("/hello")
@PreAuthorize("hasAuthority('system:dept:list')")
public String hello(){
return "hello";
}
4.2.4.11 跨域
浏览器出于安全的考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题,需要进行处理让前端能进行跨域请求。
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
// 是否允许 cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的 header 属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}
开启 Spring Security 的跨域访问
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors();
}
|