一:自定义资源认证规则
自定义资源认证规则可以通过重写WebSecurityConfigurerAdapter的configure方法实现,如下是一个简单案例。
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 匹配路径为/hello的请求,并直接放行
.mvcMatchers("/hello").permitAll() // 放行资源需要写在前面
// 对剩下的所有请求拦截做登录验证
.anyRequest().authenticated()
.and()
// 认证方式为表单登录
.formLogin();
}
}
当然也可以不用一次性写完,还可以分两次写:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated();
http.formLogin();
}
}
1.1:请求处理策略
请求处理策略由ExpressionUrlAuthorizationConfigurer的内部类AuthorizedUrl提供:
public class AuthorizedUrl {
private List<? extends RequestMatcher> requestMatchers;
private boolean not;
/**
* 创建一个新的实例
* @param requestMatchers 要映射的RequestMatcher实例
*/
private AuthorizedUrl(List<? extends RequestMatcher> requestMatchers) {
this.requestMatchers = requestMatchers;
}
protected List<? extends RequestMatcher> getMatchers() {
return this.requestMatchers;
}
/**
* Negates the following expression.
* @return the {@link ExpressionUrlAuthorizationConfigurer} for further
*/
public AuthorizedUrl not() {
this.not = true;
return this;
}
/**
* 指定匹配资源访问需要的角色
* @param role 需要的角色(即用户、管理员等)。它不能以“ROLE_”开头,因为这是自动插入的
*/
public ExpressionInterceptUrlRegistry hasRole(String role) {
return access(ExpressionUrlAuthorizationConfigurer.hasRole(role));
}
/**
* 指定匹配资源访问需要的角色
* @param roles 所需角色,可以穿多个,请求只要有其中任意一个角色即视为通过
*/
public ExpressionInterceptUrlRegistry hasAnyRole(String... roles) {
return access(ExpressionUrlAuthorizationConfigurer.hasAnyRole(roles));
}
/**
* 指定匹配资源访问需要的特定权限。
* @param authority 要求的权限(类似于ROLE_USER, ROLE_ADMIN之类的).
*/
public ExpressionInterceptUrlRegistry hasAuthority(String authority) {
return access(ExpressionUrlAuthorizationConfigurer.hasAuthority(authority));
}
/**
* 指定匹配资源访问需要的特定权限。
* @param authorities 要求的权限,可以传多个,请求只有有其中任意一个权限即视为通过
*/
public ExpressionInterceptUrlRegistry hasAnyAuthority(String... authorities) {
return access(ExpressionUrlAuthorizationConfigurer
.hasAnyAuthority(authorities));
}
/**
* 指定能访问匹配的资源的固定IP
* @param ipaddressExpression ip地址 (例如192.168.1.79) 或者本地子网(例如 192.168.0/24)
*/
public ExpressionInterceptUrlRegistry hasIpAddress(String ipaddressExpression) {
return access(ExpressionUrlAuthorizationConfigurer
.hasIpAddress(ipaddressExpression));
}
/**
* 任何请求都允许访问匹配资源
*/
public ExpressionInterceptUrlRegistry permitAll() {
return access(permitAll);
}
/**
* 允许匿名用户访问匹配资源
*/
public ExpressionInterceptUrlRegistry anonymous() {
return access(anonymous);
}
/**
* 匹配资源只允许rememberMe用户访问
*/
public ExpressionInterceptUrlRegistry rememberMe() {
return access(rememberMe);
}
/**
* 不允许任何用户访问匹配资源
*/
public ExpressionInterceptUrlRegistry denyAll() {
return access(denyAll);
}
/**
* 匹配资源只能被已认证用户访问
*/
public ExpressionInterceptUrlRegistry authenticated() {
return access(authenticated);
}
/**
* 匹配资源只能被已认证且不是rememberMe用户访问
*/
public ExpressionInterceptUrlRegistry fullyAuthenticated() {
return access(fullyAuthenticated);
}
/**
* 语文不好不知道怎么翻译,所以放上原文
* Allows specifying that URLs are secured by an arbitrary expression
* @param attribute the expression to secure the URLs (i.e.
* "hasRole('ROLE_USER') and hasRole('ROLE_SUPER')")
*/
public ExpressionInterceptUrlRegistry access(String attribute) {
if (not) {
attribute = "!" + attribute;
}
interceptUrl(requestMatchers, SecurityConfig.createList(attribute));
return ExpressionUrlAuthorizationConfigurer.this.REGISTRY;
}
}
可以看到以上处理策略返回的都是ExpressionInterceptUrlRegistry类,可以通过下面的类图知道,ExpressionInterceptUrlRegistry继承了AbstractRequestMatcherRegistry抽象类
AbstractRequestMatcherRegistry抽象类的方法如下
可以看到其具有三大匹配器的所有重载方法,因此我们可以知道匹配器可以多次使用,以及不同匹配器可以搭配使用:
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.mvcMatchers("/hello2").permitAll()
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.antMatchers("/hello2").permitAll()
1.2:匹配器
Spring Security 提供了三种强大的匹配器(Matcher)来实现这一目标,分别是MVC 匹配器、Ant 匹配器以及正则表达式匹配器。
1.2.1:MVC 匹配器
MVC 匹配器的使用方法比较简单,就是基于 HTTP 端点的访问路径进行匹配,如下所示:
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.mvcMatchers("/hello2").permitAll()
查看mvcMatchers方法源码:
@Override
public MvcMatchersAuthorizedUrl mvcMatchers(String... patterns) {
return mvcMatchers(null, patterns);
}
可知,参数为不定项参数,因此可以同时传入多个字符串:
http.authorizeRequests()
.mvcMatchers("/hello1", "/hello2").permitAll();
同时,如果我们想要对某个路径下的所有子路径都指定同样的访问控制,那么只需要在该路径后面添加“ * ”号即可,示例代码如下所示:
http.authorizeRequests()
.mvcMatchers("/hello/*").permitAll();
实际开发中一个 Controller 中可能存在两个路径完全一样的 HTTP 端点,因为对于 HTTP 端点而言,就算路径一样,只要所使用的 HTTP 方法不同,那就是不同的两个端点。针对这种场景,MVC 匹配器还提供了重载的 mvcMatchers 方法,如下所示:
@Override
public MvcMatchersAuthorizedUrl mvcMatchers(HttpMethod method, String... mvcPatterns) {
return new MvcMatchersAuthorizedUrl(createMvcMatchers(method, mvcPatterns));
}
这样,我们就可以把 HTTP 方法作为一个访问的维度进行控制,示例代码如下所示:
http.authorizeRequests()
.mvcMatchers(HttpMethod.POST, "/hello/*").permitAll();
而如果没有匹配器匹配到的路径其访问不受任何的限制,如下示例:
http.authorizeRequests()
.mvcMatchers(HttpMethod.POST, "/hello").authenticated()
.mvcMatchers(HttpMethod.GET, "/hello").authenticated()
如果仅仅只配置了这两个,那么如果有个"/hello1"请求,将会直接获取资源,不收限制,效果类似于下面:
http.authorizeRequests()
.mvcMatchers(HttpMethod.POST, "/hello").authenticated()
.mvcMatchers(HttpMethod.GET, "/hello").authenticated()
.anyRequest().permitAll();
1.2.2:Ant 匹配器
Ant 匹配器的表现形式和使用方法与前面介绍的 MVC 匹配器非常相似,它也提供了如下所示的三个重载方法来完成请求与 HTTP 端点地址之间的匹配关系:
public C antMatchers(HttpMethod method) {
return antMatchers(method, new String[] { "/**" });
}
public C antMatchers(HttpMethod method, String... antPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure antMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.antMatchers(method, antPatterns));
}
public C antMatchers(String... antPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure antMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.antMatchers(antPatterns));
}
从方法定义上不难明白,我们可以组合指定请求的 HTTP 方法以及匹配的模式,例如:
http.authorizeRequests()
.antMatchers("/hello").authenticated();
虽然,从使用方式上看,Ant 匹配器和 MVC 匹配器并没有什么区别,但在日常开发过程中,一般推荐使用 MVC 匹配器而不是 Ant 匹配器,原因就在于 Ant 匹配器在匹配路径上有一些风险,主要体现在对于"/"的处理上。为了更好地说明,我举一个简单的例子。
基于上面的这行配置,如果你发送一个这样的 HTTP 请求,但事实上这个请求是匹配不到的
http://localhost:8080/hello
现在,如果你把 HTTP 请求调整为这样,在请求地址最后添加了一个”/”符号,那么就会得到正确的访问结果:
http://localhost:8080/hello/
显然,Ant 匹配器处理请求地址的方式有点让人感到困惑,而 MVC 匹配器则没有这个问题,无论在请求地址最后是否存在“/”符号,它都能完成正确的匹配。
1.2.3:正则表达式匹配器
最后我要介绍的是正则表达式匹配器,它提供了如下所示的两个配置方法:
public C regexMatchers(String... regexPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure regexMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.regexMatchers(regexPatterns));
}
public C requestMatchers(RequestMatcher... requestMatchers) {
Assert.state(!this.anyRequestConfigured, "Can't configure requestMatchers after anyRequest");
return chainRequestMatchers(Arrays.asList(requestMatchers));
}
使用这一匹配器的主要优势在于它能够基于复杂的正则表达式对请求地址进行匹配,这是 MVC 匹配器和 Ant 匹配器无法实现的,可以看一下如下所示的这段配置代码:
http.authorizeRequests()
.mvcMatchers("/email/{email:.*(.+@.+\\.com)}")
.permitAll()
.anyRequest()
.denyAll();
可以看到,这段代码就对常见的邮箱地址进行了匹配,只有输入的请求是一个合法的邮箱地址才能允许访问。
二:自定义登录界面
自定义登录页面配置如下:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.regexMatchers("/login1").permitAll() // 注意要开放登录请求接口
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login1") // 指定登录页面路径
.loginProcessingUrl("/doLogin"); // 自定义登录界面就必须指定登录请求路径
}
}
当然一般来说这里应该要演示页面跳转,但是在开发中,路由跳转应该由前端控制,后端不应该干预路由,后端路由通常用在前后端不分离项目中,而现在前后端分离才是主流的开发方式,所以我就不演示跳转登录页面了,但我会说一下流程
当客户端发送请求时,会进入Spring Security的过滤器,如果请求访问的资源需要认证,而客户端没有携带认证信息,那么会在请求到达FilterSecurityInterceptor
时会拦截请求并抛出AccessDeniedException
异常,抛出的AccessDeniedException
异常会被ExceptionTranslationFilter
捕获,这个Filter中会调用LoginUrlAuthenticationEntryPoint#commence
方法给客户端302,要求客户端重定向请求loginPage()
配置的路径,默认为/login
然后前后端分离与不分离的处理方式就在这一步开始有所区分,对于不分离的项目,后端控制路由,那么需要写如下controller
@Controller
public class TestController {
@GetMapping("/login1")
public String login1() {
return "login.html";
}
}
从上面的配置我们知道,当我们自定义了登录路径,也就是loginPage("/login1")
,那么ExceptionTranslationFilter捕获到未登录异常的时候会要求客户端访问我们自定义的登录路径,也就是"/login1",然后再由我们自己写的login1()方法去重定向到我们自己写的login.html页面
要注意的是,捕获到未认证异常,客户端会重定向请求"/login1",而如果"/login1"是跳转页面指令,那么才会跳转页面,而前后端分离的处理,就是在login1()方法中直接返回权限失败信息而非页面跳转,自定义一个状态码,前端的响应拦截器拦截后识别状态码由前端进行相关处理,前端可以选择跳转到页面,也可以选择弹出一个登录窗提示用户,这些都由前端决定,可见这种处理方式比后端路由更加灵活自由。当然目前来说可以借助loginPage实现这一效果,但是事实上Spring Security有更直接的方法,也就是通过successHandler来实现,这个会在后面详细说明。
说完登录页面,那么接下来是登录请求,也就是我们配置的loginProcessingUrl("/doLogin")
,我们可以看看处理登录的formLogin()源码:
public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
return getOrApply(new FormLoginConfigurer<>());
}
可见其调用了FormLoginConfigurer
的实现,从其源码可以看到,FormLoginConfigurer
是调用了UsernamePasswordAuthenticationFilter
过滤器,
public FormLoginConfigurer() {
super(new UsernamePasswordAuthenticationFilter(), null);
usernameParameter("username");
passwordParameter("password");
}
而过滤器会调用attemptAuthentication
方法处理登录请求,我们从源码可以看出,登录请求必须是POST请求,否则会抛出异常Authentication method not supported: " + request.getMethod()
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);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
可以看到,这个过滤器调用obtainUsername()和obtainPassword()方法从请求中分别拿到用户名和密码,但我们需要注意的是,他怎么知道用户名和密码是http请求里的哪个字段?Spring Security当然不知道,因此Spring Security默认用户名字段默认叫做username,密码字段默认叫做password,因此如果不做额外配置,登录请求时,用户名必须叫做username,密码必须叫做password,否则无法正确认证,我们可以继续看源码,看Spring Security的处理
进入obtainUsername()方法,可以看到,usernameParameter是用户名字段名
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
查看usernameParameter的定义,可以看到,用户名字段默认叫做username,密码字段默认叫做password
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;
如果我们要自定义用户名和密码,可以这样指定:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.regexMatchers("/login1").permitAll()
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login1")
.loginProcessingUrl("/doLogin")
.usernameParameter("user") // 自定义用户名字段名
.passwordParameter("psd"); // 自定义密码字段名
}
}
查看源码可以知道,usernameParameter调用了setUsernameParameter方法,并把我们自定义的字符串传了进去
public FormLoginConfigurer<H> usernameParameter(String usernameParameter) {
getAuthenticationFilter().setUsernameParameter(usernameParameter);
return this;
}
查看setUsernameParameter
方法,可以看到还有个断言,如果为空则报错。不为空就把值赋给了usernameParameter,usernameParameter也就不是默认的SPRING_SECURITY_FORM_USERNAME_KEY
了
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
然后,formLogin登录方式除了提供下面这些配置,还提供了一些其他的配置方法
我们可以看到formLogin()
返回类型是FormLoginConfigurer
public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
return getOrApply(new FormLoginConfigurer<>());
}
进入FormLoginConfigurer
,我们可以看到FormLoginConfigurer
有这些方法:
我截取了没有说明的方法源码,如下:
public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
AbstractAuthenticationFilterConfigurer<H, FormLoginConfigurer<H>, UsernamePasswordAuthenticationFilter> {
/**
* 认证失败后重定向请求的路径
* 在前后端不分离的项目中,传入的url一般是对应一个页面跳转的controller,比如跳转到失败页面
* 在前后端分离的项目中,传入的url一般是对应一个返回失败信息的controller
* @param forwardUrl 请求的路径
*/
public FormLoginConfigurer<H> failureForwardUrl(String forwardUrl) {
failureHandler(new ForwardAuthenticationFailureHandler(forwardUrl));
return this;
}
/**
* 认证成功后重定向请求的路径
* 在前后端不分离的项目中,传入的url一般是对应一个页面跳转的controller,比如跳转到主页面
* 在前后端分离的项目中,传入的url一般是对应一个返回成功信息的controller
* @param forwardUrl 请求的路径
*/
public FormLoginConfigurer<H> successForwardUrl(String forwardUrl) {
successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl));
return this;
}
/*
* 源码没有做说明,即non-Javadoc
* 但可以了解到,可以配合setRequiresAuthenticationRequestMatcher设置过滤器处理登录逻辑的请求URL
* 配合使用可以指定其它名称覆盖 /login
*/
@Override
protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
return new AntPathRequestMatcher(loginProcessingUrl, "POST");
}
/**
* 获取用于提交用户名的HTTP参数。
* 默认为username,但可以通过usernameParameter()自定义
*/
private String getUsernameParameter() {
return getAuthenticationFilter().getUsernameParameter();
}
/**
* 获取用于提交密码的HTTP参数。
* 默认为password,但可以通过passwordParameter()自定义
*/
private String getPasswordParameter() {
return getAuthenticationFilter().getPasswordParameter();
}
}
当然可以配置的方法远远不止这些,因为FormLoginConfigurer
继承了AbstractAuthenticationFilterConfigurer
抽象类,因此也拥有了抽象类的方法:
以下是我截取的未说明过的访问权限为public的方法源码:
public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter>
extends AbstractHttpConfigurer<T, B> {
/**
* 认证成功后进行重定向,如果之前有请求路径,则会优先跳转之前的请求路径
* @param defaultSuccessUrl 默认认证成功时请求的路径
*/
public final T defaultSuccessUrl(String defaultSuccessUrl) {
return defaultSuccessUrl(defaultSuccessUrl, false);
}
/**
* 认证成功后进行重定向,如果之前有请求路径,则会优先跳转之前的请求路径
* @param defaultSuccessUrl 默认认证成功时请求的路径
* @param alwaysUse 如果设置为true,则只会跳转到defaultSuccessUrl而不是之前请求的路径,此时效果与successForwardUrl一样
*/
public final T defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) {
SavedRequestAwareAuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
handler.setDefaultTargetUrl(defaultSuccessUrl);
handler.setAlwaysUseDefaultTargetUrl(alwaysUse);
this.defaultSuccessHandler = handler;
return successHandler(handler);
}
/**
* 指定自定义的AuthenticationDetailsSource,默认为WebAuthenticationDetailsSource
* @param authenticationDetailsSource 自定义的AuthenticationDetailsSource
*/
public final T authenticationDetailsSource(
AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
this.authenticationDetailsSource = authenticationDetailsSource;
return getSelf();
}
/**
* 认证成功回调方法
* @param successHandler AuthenticationSuccessHandler
*/
public final T successHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return getSelf();
}
/**
* 与调用permitAll(true)等效
*/
public final T permitAll() {
return permitAll(true);
}
/**
* 确保向所有用户授予failureUrl(String)以及HttpSecurityBuilder、getLoginPage和getLoginProcessingUrl的URL访问权限。
* @param permitAll true则授予对URL的访问权限false则跳过此步骤
*/
public final T permitAll(boolean permitAll) {
this.permitAll = permitAll;
return getSelf();
}
/**
* 认证失败时前端显示的URL,调用failureHandler的快捷方式,默认是"/login?error"
* @param authenticationFailureUrl 认证失败URL (默认是"/login?error")
*/
public final T failureUrl(String authenticationFailureUrl) {
T result = failureHandler(new SimpleUrlAuthenticationFailureHandler(
authenticationFailureUrl));
this.failureUrl = authenticationFailureUrl;
return result;
}
/**
* 认证失败回调方法
* 默认情况下使用SimpleUrlAuthenticationFailureHandler实现类对象重定向到"/login?error"
* @param authenticationFailureHandler AuthenticationFailureHandler
*/
public final T failureHandler(
AuthenticationFailureHandler authenticationFailureHandler) {
this.failureUrl = null;
this.failureHandler = authenticationFailureHandler;
return getSelf();
}
/**
* 如果指定了自定义登录页,则返回true,否则返回false
* @return
*/
public final boolean isCustomLoginPage() {
return customLoginPage;
}
}
三:自定义认证成功处理
在前后端分离开发项目中,后端没有页面,此时的认证成功跳转页面也就变成了认证成功返回成功信息,这时候我们就要用到AbstractAuthenticationFilterConfigurer
抽象类提供的successHandler
方法,源码与注释在上面已经列出,这里我们详细看看源码,可见传入的是一个AuthenticationSuccessHandler
实现类的对象
/**
* 认证成功回调方法
* @param successHandler AuthenticationSuccessHandler
*/
public final T successHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return getSelf();
}
我们可以查看AuthenticationSuccessHandler的源码:
public interface AuthenticationSuccessHandler {
/**
* 认证成功回调方法
* @param request 认证成功的请求
* @param response 响应
* @param chain 可以被用在其他过滤器中的过滤器链
* @param authentication 认证过程中产生的Authentication对象
* @since 5.2.0
*/
default void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authentication)
throws IOException, ServletException{
onAuthenticationSuccess(request, response, authentication);
chain.doFilter(request, response);
}
/**
* 认证成功回调方法
* @param request 认证成功的请求
* @param response 响应
* @param authentication 认证过程中产生的Authentication对象
*/
void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication)
throws IOException, ServletException;
}
我们可以看到,该接口有如下三个实现:
下面是其类图:
事实上,defaultSuccessUrl
就是通过SavedRequestAwareAuthenticationSuccessHandler
实现认证成功后进行重定向,如果之前有请求路径,则会优先跳转之前的请求路径的,因为SavedRequestAwareAuthenticationSuccessHandler
会保存请求,而successForwardUrl
是通过ForwardAuthenticationSuccessHandler
实现的,这些都可以从我上面列出的源码看出。同样,我们也可以看出,如果我们想响应一个json数据,也就是自定义认证成功处理,可以通过实现AuthenticationSuccessHandler
,自定义onAuthenticationSuccess
方法
我们可以这样自己实现AuthenticationSuccessHandler
接口:
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Map<String, Object> result = new HashMap<>();
result.put("msg", "登录成功");
result.put("status", 200);
result.put("authentication", authentication);
response.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}
}
然后在配置里传入:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler(new CustomAuthenticationSuccessHandler());
}
}
请求认证成功后就可以返回json信息了(用户名是张三是因为我在yml里配置了默认用户名,密码是为了安全性所以为null):
四:获取认证失败信息
我们可以先通过debug查看一下认证失败的处理流程,上面已经分析出来,用户身份认证是通过UsernamePasswordAuthenticationFilter
这个过滤器实现的,因此我们进入过滤器开始打断点:
这个attemptAuthentication方法主要做的事情其实是把客户端传来的用户名密码包装成token,然后传入getAuthenticationManager().authenticate()
进行验证,因此需要进入该方法,如下图,可见抛出异常,异常类型为BadCredentialsException
,也就是密码错误,但detailMessage是用户名或密码错误,通常密码错误都是报用户名或密码错误,因为这可以让用户检查自己的账号是否正确,同时也避免了别有用心的人试探数据库账号信息,但为什么是detailMessage中文,我也不清楚,或许是idea做的
但这里并没有要存储这个错误信息,我们可以继续往下看,异常被交由unsuccessfulAuthentication
方法处理,看这个名字就感觉里面应该是对错误信息的处理,可以进去看看
unsuccessfulAuthentication
方法:
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
// 清空SecurityContextHolder
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);
}
// 让rememberMe令牌失效
rememberMeServices.loginFail(request, response);
// 执行认证失败的函数
failureHandler.onAuthenticationFailure(request, response, failed);
}
从源码可以看到,它调用了failureHandler去处理失败信息,这里也是Spring Security默认的失败处理,因此,我们知道如果要自定义失败处理,需要实现AuthenticationFailureHandler
接口,自定义onAuthenticationFailure方法。同时我们也可以看到,AuthenticationFailureHandler
默认实现类是SimpleUrlAuthenticationFailureHandler
我们可以进入SimpleUrlAuthenticationFailureHandler
查看默认实现:
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
// defaultFailureUrl默认是/login?error,可以通过failureUrl方法自定义
if (defaultFailureUrl == null) {
logger.debug("No failure URL set, sending 401 Unauthorized error");
response.sendError(HttpStatus.UNAUTHORIZED.value(),
HttpStatus.UNAUTHORIZED.getReasonPhrase());
}
else {
// 存储异常信息
saveException(request, exception);
// 如果设置了defaultFailureUrl,存储异常信息后让客户端重定向访问defaultFailureUrl
if (forwardToDestination) {
logger.debug("Forwarding to " + defaultFailureUrl);
request.getRequestDispatcher(defaultFailureUrl)
.forward(request, response);
}
else {
logger.debug("Redirecting to " + defaultFailureUrl);
redirectStrategy.sendRedirect(request, response, defaultFailureUrl);
}
}
}
可以看到,报错信息的存储应该是由saveException()
方法实现,进去看看:
protected final void saveException(HttpServletRequest request,
AuthenticationException exception) {
// 如果是forward请求,就将错误信息存进request
// 异常字段名为:"SPRING_SECURITY_LAST_EXCEPTION"
if (forwardToDestination) {
request.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception);
}
else {
HttpSession session = request.getSession(false);
// 如果不是forward请求,那么将错误信息存进session
// 异常字段名为:"SPRING_SECURITY_LAST_EXCEPTION"
if (session != null || allowSessionCreation) {
request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION,
exception);
}
}
}
通过源码我们知道,如果要获取错误信息,就得分清楚跳转是重定向跳转还是forward跳转,因为虽然两者的错误信息字段名一样,但错误信息存在不同的地方。默认使用的是重定向跳转。跳转方式可以通过配置设置,注意failureForwardUrl和failureUrl只能配置其中一个
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler(new CustomAuthenticationSuccessHandler())
.failureForwardUrl("/login") // 认证失败后forward跳转
.failureUrl("/login"); // 认证失败后redirect跳转
}
}
五:自定义认证失败处理
与之前同理,前后端分离开发时,路由是前端控制,因此,认证失败跳转页面也就变成了认证失败返回失败JSON信息,这时候我们就要用到AbstractAuthenticationFilterConfigurer
抽象类提供的failureHandler
方法,源码与注释在上面已经列出,这里我们详细看看源码,可见传入的是一个AuthenticationFailureHandler
实现类的对象
/**
* 认证失败回调方法
* 默认情况下使用SimpleUrlAuthenticationFailureHandler实现类对象重定向到"/login?error"
* @param authenticationFailureHandler AuthenticationFailureHandler
*/
public final T failureHandler(
AuthenticationFailureHandler authenticationFailureHandler) {
this.failureUrl = null;
this.failureHandler = authenticationFailureHandler;
return getSelf();
}
我们可以查看AuthenticationFailureHandler
接口的源码:
public interface AuthenticationFailureHandler {
/**
* 认证失败回调方法
* @param request 认证失败的请求
* @param response 响应
* @param exception 认证失败抛出的异常
*/
void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException;
}
我们可以看到,该接口有如下实现
下面是其类图:
事实上,failureForwardUrl()
方法就是采用了ForwardAuthenticationFailureHandler
的实现,而defaultSuccessUrl()
方法是采用了SimpleUrlAuthenticationFailureHandler
的实现,这些都可以从我上面列出的源码看出。同样,我们也可以看出,如果我们想响应一个JSON数据,也就是自定义认证失败处理,可以通过实现AuthenticationFailureHandler
,自定义onAuthenticationFailure
方法,然后传入failureHandler()
方法
我们可以这样自己实现AuthenticationFailureHandler
接口:
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
Map<String, Object> result = new HashMap<>();
result.put("msg", "认证失败");
result.put("status", 400);
result.put("authentication", exception.getMessage());
response.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}
}
六:注销登录配置
Spring Security中也提供了默认的注销登录配置,在开发时也可以按照自己的需求对注销进行个性化定制。因为有默认配置,因此我先不做任何配置去测试一次注销登录操作
登录成功:
登录成功后可以直接访问受限资源:
注销接口默认是"/logout",因此可以请求一次/logout
点击确认回到登录页面:
我们也可以去自定义注销登录的相关配置,HttpSecurity提供了两个logout重载方法:
/*
* 提供注销支持。这在使用WebSecurityConfigurerAdapter时自动应用。
* 默认情况下,访问“/logout”将通过使HTTP会话无效、清除任何已配置的rememberMe()身份验证、清除SecurityContextHolder
* 完成后重定向到“/logout-success”。
*/
public LogoutConfigurer<HttpSecurity> logout() throws Exception {
return getOrApply(new LogoutConfigurer<>());
}
/*
* 提供注销支持。这在使用WebSecurityConfigurerAdapter时自动应用。
* 默认情况下,访问“/logout”将通过使HTTP会话无效、清除任何已配置的rememberMe()身份验证、清除SecurityContextHolder
* 完成后重定向到“/logout-success”。
* @Params: logoutCustomizer 自定义程序为LogoutConfigurer提供更多选项
*/
public HttpSecurity logout(Customizer<LogoutConfigurer<HttpSecurity>> logoutCustomizer) throws Exception {
logoutCustomizer.customize(getOrApply(new LogoutConfigurer<>()));
return HttpSecurity.this;
}
当我们使用无参的logout()时,可以看到它返回的是一个LogoutConfigurer,即注销配置类,所以可以知道我们可以继续去链式配置注销服务,进入LogoutConfigurer,可以看到它提供了这些配置,以下是部分源码:
public final class LogoutConfigurer<H extends HttpSecurityBuilder<H>> extends
AbstractHttpConfigurer<LogoutConfigurer<H>, H> {
/**
* 添加LogoutHandler实现类对象,LogoutHandler是执行注销的类必须实现的接口
* 默认情况下有SecurityContextLogoutHandler和LogoutSuccessEventPublishingLogoutHandler
* @param logoutHandler LogoutHandler实现类对象
*/
public LogoutConfigurer<H> addLogoutHandler(LogoutHandler logoutHandler) {
Assert.notNull(logoutHandler, "logoutHandler cannot be null");
this.logoutHandlers.add(logoutHandler);
return this;
}
/**
* 指定SecurityContextLogoutHandler是否应在注销时清除
* @param clearAuthentication
*/
public LogoutConfigurer<H> clearAuthentication(boolean clearAuthentication) {
contextLogoutHandler.setClearAuthentication(clearAuthentication);
return this;
}
/**
* 配置SecurityContextLogoutHandler,使HttpSession在注销时失效。
* @param invalidateHttpSession true则无效,反之有效,默认无效
*/
public LogoutConfigurer<H> invalidateHttpSession(boolean invalidateHttpSession) {
contextLogoutHandler.setInvalidateHttpSession(invalidateHttpSession);
return this;
}
/**
* 触发注销的URL(默认为“/logout”)
* 如果启用了CSRF保护(默认),则请求也必须是POST。如果禁用CSRF保护,则允许使用任何请求方式
* @param logoutUrl 触发注销的URL
*/
public LogoutConfigurer<H> logoutUrl(String logoutUrl) {
this.logoutRequestMatcher = null;
this.logoutUrl = logoutUrl;
return this;
}
/**
* 触发注销的RequestMatcher。在大多数情况下,用户将使用logoutUrl
* @param logoutRequestMatcher 用于确定是否应注销的RequestMatcher
*/
public LogoutConfigurer<H> logoutRequestMatcher(RequestMatcher logoutRequestMatcher) {
this.logoutRequestMatcher = logoutRequestMatcher;
return this;
}
/**
* 注销后要重定向到的URL。默认值为“/login?logout”
* 这是使用simplerullogoutsuccesshandler调用logoutSuccessHandler的快捷方式
* @param logoutSuccessUrl 注销后重定向到的URL
*/
public LogoutConfigurer<H> logoutSuccessUrl(String logoutSuccessUrl) {
this.customLogoutSuccess = true;
this.logoutSuccessUrl = logoutSuccessUrl;
return this;
}
/**
* permitAll(boolean)方法参数为true时的快捷方式
*/
public LogoutConfigurer<H> permitAll() {
return permitAll(true);
}
/**
* 指定注销成功时要删除的Cookie的名称
* 这是一种使用CookieClearingLogoutHandler调用addLogoutHandler的快捷方式
* @param cookieNamesToClear 注销成功时要删除的Cookie的名称
*/
public LogoutConfigurer<H> deleteCookies(String... cookieNamesToClear) {
return addLogoutHandler(new CookieClearingLogoutHandler(cookieNamesToClear));
}
/**
* 设置要使用的LogoutSuccessHandler
* 如果指定了此选项,则忽略logoutSuccessUrl
* @param logoutSuccessHandler
*/
public LogoutConfigurer<H> logoutSuccessHandler(
LogoutSuccessHandler logoutSuccessHandler) {
this.logoutSuccessUrl = null;
this.customLogoutSuccess = true;
this.logoutSuccessHandler = logoutSuccessHandler;
return this;
}
/**
* 设置默认的LogoutSuccessHandler
* 如果没有指定,则将使用SimpleUrlLogoutSuccessHandler
* @param handler LogoutSuccessHandler
* @param preferredMatcher 指定默认的RequestMatcher
*/
public LogoutConfigurer<H> defaultLogoutSuccessHandlerFor(
LogoutSuccessHandler handler, RequestMatcher preferredMatcher) {
Assert.notNull(handler, "handler cannot be null");
Assert.notNull(preferredMatcher, "preferredMatcher cannot be null");
this.defaultLogoutSuccessHandlerMappings.put(preferredMatcher, handler);
return this;
}
/**
* 授予所有用户对logoutSuccessUrl和logoutUrl的访问权限
* @param permitAll 如果true授予访问权限,false则不会执行任何操作
*/
public LogoutConfigurer<H> permitAll(boolean permitAll) {
this.permitAll = permitAll;
return this;
}
}
前后端不分离的开发方式我就不演示了,可以看上面的注释配置,一般就是.logout().logoutSuccessUrl()
什么的。在前后端分离的开发方式里,我们可以自定义LogoutSuccessHandle来实现返回注销成功的JSON信息,注释上面有,我们在这对其详细说明,我们可以看到是传入一个LogoutSuccessHandler实现类对象,所以我们可以通过定义一个类实现LogoutSuccessHandler接口,自定义onLogoutSuccess()方法,然后将其实例化传入logoutSuccessHandler方法
/**
* 设置要使用的LogoutSuccessHandler
* 如果指定了此选项,则忽略logoutSuccessUrl
* @param logoutSuccessHandler
*/
public LogoutConfigurer<H> logoutSuccessHandler(
LogoutSuccessHandler logoutSuccessHandler) {
this.logoutSuccessUrl = null;
this.customLogoutSuccess = true;
this.logoutSuccessHandler = logoutSuccessHandler;
return this;
}
LogoutSuccessHandler接口如下
public interface LogoutSuccessHandler {
// 注销成功的回调函数
void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException;
}
可以看到这个接口有如下四个实现类:
类图如下:
其中SimpleUrlLogoutSuccessHandler
是Spring Security默认使用的实现类,logoutSuccessUrl()
也是通过该类实现的。
我们现在自定义一个实现类:
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Map<String, Object> result = new HashMap<>();
result.put("msg", "注销成功");
result.put("status", 200);
result.put("authentication", authentication);
response.setContentType("application/json;charset=UTF-8");
String s = new ObjectMapper().writeValueAsString(result);
response.getWriter().println(s);
}
}
然后做如下配置:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler(new CustomAuthenticationSuccessHandler())
.failureHandler(new CustomAuthenticationFailureHandler())
.and()
.logout()
.logoutSuccessHandler(new CustomLogoutSuccessHandler());
}
}
就可以了:
七:获取用户认证信息
Spring Security会将登录用户数据保存在Session中。但是,为了使用方便,Spring Security
在此基础上还做了一些改进,其中最主要的一个变化就是线程绑定。当用户登录成功
后,Spring Security会将登录成功的用户信息保存到SecurityContextHolder中。
SecurityContextHolder中的数据保存默认是通过ThreadLocal来实现的,使用ThreadLocal
创建的变量只能被当前线程访问,不能被其他线程访问和修改,也就是用户数据和请求线程
绑定在一起。 当登录请求处理完毕后,Spring Security会将SecurityContextHolder中的数据
拿出来保存到Session中,同时将SecurityContextHolder中的数据清空。以后每当有请求到
来时,Spring Security就会先从Session中取出用户登录数据,保存到SecurityContextHolder
中,方便在该请求的后续处理过程中使用,同时在请求结束时将SecurityContextHolder中
的数据拿出来保存到Session中,然后将SecurityContextHolder中的数据清空。
实际上SecurityContextHolder中存储是SecurityContext,在SecurityContext中存储是
Authentication。这是一种策略设计模式
以下是其部分源码:
public class SecurityContextHolder {
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
public static final String MODE_GLOBAL = "MODE_GLOBAL";
public static final String SYSTEM_PROPERTY = "spring.security.strategy";
private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
private static SecurityContextHolderStrategy strategy;
private static int initializeCount = 0;
// 类加载时进行初始化
static {
initialize();
}
/**
* 清除当前线程存储的SecurityContext对象
*/
public static void clearContext() {
strategy.clearContext();
}
/**
* 获取当前线程存储的SecurityContext对象
*/
public static SecurityContext getContext() {
return strategy.getContext();
}
/**
* 主要出于故障排除目的,这个方法显示了类初始化SecurityContextHolderStrategy的次数。
* @return 初始化次数(应该是1,除非调用setStrategyName切换到另一个strategy)
*/
public static int getInitializeCount() {
return initializeCount;
}
// 初始化方法
private static void initialize() {
if (!StringUtils.hasText(strategyName)) {
// 设置默认值
strategyName = MODE_THREADLOCAL;
}
if (strategyName.equals(MODE_THREADLOCAL)) {
strategy = new ThreadLocalSecurityContextHolderStrategy();
}
else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
}
else if (strategyName.equals(MODE_GLOBAL)) {
strategy = new GlobalSecurityContextHolderStrategy();
}
else {
// 尝试加载自定义策略
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
}
catch (Exception ex) {
ReflectionUtils.handleReflectionException(ex);
}
}
initializeCount++;
}
/**
* 给当前线程设置新的SecurityContext
* @param context
*/
public static void setContext(SecurityContext context) {
strategy.setContext(context);
}
/**
* 更改当前的策略。
* 不要对给定的JVM多次调用此方法,因为它将重新初始化策略,
* 并会对使用之前策略的所有现有线程产生不利影响。
* @param strategyName 应使用的strategy的完全限定类名。
*/
public static void setStrategyName(String strategyName) {
SecurityContextHolder.strategyName = strategyName;
initialize();
}
/**
* 获取当前采用的策略
*/
public static SecurityContextHolderStrategy getContextHolderStrategy() {
return strategy;
}
/**
* 将SecurityContext的创建任务委托给当前的SecurityContextHolderStrategy实现类。
*/
public static SecurityContext createEmptyContext() {
return strategy.createEmptyContext();
}
}
从源码中我们可以知道,为了进一步了解SecurityContextHolder的工作方式,我们需要了解一下这个strategy,根据initialize()
方法我们可以知道,strategy是SecurityContextHolderStrategy
的实现类对象,并且有多个实现类,我们可以看看SecurityContextHolderStrategy
接口源码:
public interface SecurityContextHolderStrategy {
/**
* 清除当前存储的SecurityContext对象
*/
void clearContext();
/**
* 获取当前存储的SecurityContext对象
* @return 返回一个context(不能为null,如有必要,创建默认实现)
*/
SecurityContext getContext();
/**
* 设置存储的SecurityContext对象
* @param context 不能为null,实现时必须检查是否传递了null,并在传null时抛出IllegalArgumentException
*/
void setContext(SecurityContext context);
/**
* 创建空的SecurityContext对象
*/
SecurityContext createEmptyContext();
}
我们可以看到该接口有如下实现类:
类图如下:
ThreadLocalSecurityContextHolderStrategy
的实现就是上面说的本地线程绑定,即将SecurityContext存放在ThreadLocal中,Threadlocal的特点是在哪个线程中存储就要在哪个线程中读取,这其实非常适合web应用,因为在默认情况下,一个请求无论经过多少Filter到达Servlet,都是由一个线程来处理的。 这也是SecurityContextHolder的默认存储策略,这种存储策略意味着如果在具体的业务处理代码中,开启了子线程,在子线程中去获取登录用户数据,就会获取不到。该实现类对应MODE_THREADLOCAL
模式
GlobalSecurityContextHolderStrategy
是全局绑定,这种存储模式实际上是将数据保存在一个静态变量中,在JavaWeb开发中极少用到,因为很容易造成并发时数据错误问题。该实现类对应MODE_GLOBAL
模式
InheritableThreadLocalSecurityContextHolderStrategy
的实现允许父子线程之间拷贝数据,非常适合多线程环境,如果希望子线程也能获取到登录用户的数据,可以使用这个实现类。该实现类对应MODE_INHERITABLETHREADLOCAL
模式
从SecurityContextHolder的initialize()
方法我们可以看出,这是一个典型的适配器设计模式,通过不同的策略获取不同的实现类
SecurityContextHolder就是管理SecurityContext的存储方式,为了进一步了解数据的存储,我们可以看看SecurityContext的源码:
public interface SecurityContext extends Serializable {
/**
* 获取当前经过身份验证的主体或者token
*/
Authentication getAuthentication();
/**
* 自定义认证信息
*/
void setAuthentication(Authentication authentication);
}
接下来,了解了原理,我们就可以通过代码来获取认证信息了
@GetMapping("/hello2")
public User Hello2() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();
System.out.println("身份信息:" + user.getUsername());
System.out.println("获取权限信息:" + user.getAuthorities());
return user;
}
八:自定义数据源
之前所有的认证用的都是内存中的信息,但开发中用户信息都是在数据库中的,因此我们需要把数据源替换成数据库。
8.1:认证原理分析
- 发起认证请求,请求中携带用户名、密码,该请求会被
UsernamePasswordAuthenticationFilter
拦截 - 在
UsernamePasswordAuthenticationFilter
中的attemptAuthentication()
方法会将请求中的用户名和密码封装成UsernamePasswordAuthenticationToken
对象,并交给AuthenticationManager
进行认证 - 认证成功,将认证信息存储到SecurityContextHodler以及调用RememberMe等功能,并回调
AuthenticationSuccessHandler
处理 - 认证失败,清除SecurityContextHodler以及RememberMe中的信息,回调
AuthticationFailureHandler
处理
这个流程的源码分析在之前的博客里已经分析过了,因此这里就不重复了,有兴趣的话可以看看:Spring Security(一) —— 整体架构与入门案例分析
从那篇博客的分析我们得知,AuthenticationManager是认证的核心类,但实际上在底层认证的时候还是离不开ProviderManager和AuthenticationProvider。他们三者有什么关系呢
- AuthenticationManager是一个认证管理器,他定义了Spring Security要执行的认证操作
- ProviderManager是AuthenticationManager接口的实现类,Spring Security认证时默认使用的就是ProviderManager
- AuthenticationProvider是针对不同的身份类型执行具体的身份认证
AuthenticationManager与ProviderManager:
ProviderManager与AuthenticationProvider:
在Spring Security中,允许系统同时支持多种不同的认证方式,例如同时支持用户名/密码认证、ReremberMe 认证、手机号码动态认证等,而不同的认证方式对应了不同的AuthenticationProvider,所以一个完整的认证流程可能由多个AuthenticationProvider来提供。
多个AuthenticationProvider将组成一个List, 这个List将由ProviderManager代理。换句话说,在ProviderManager 中存在一个List<AuthenticationProvider>,在ProviderManager中遍历List中的每一个AuthenticationProvider去执行身份认证,最终得到认证结果。只要有一个AuthenticationProvider认证通过,则视为认证成功
ProviderManager本身也可以再配置一个 AuthenticationManager 作为parent,这样当ProviderManager认证失败之后,就可以进入到parent中再次进行认证。理论上来说,ProviderManager的parent可以是任意类型的AuthenticationManager,但是通常都是由ProviderManager来扮演parent的角色,也就是ProviderManager是ProviderManager的parent。
ProviderManager本身也可以有多个,多个ProviderManager共用同一个parent。有时,一个应用程序有受保护资源的逻辑组(例如, 所有符合路径模式的网络资源,如/api/**) ,每个组可以有自己的专用AuthenticationManager。通常,每个组都是一个ProviderManager,它们共享一个父级。然后,父级是一种全局资源,作为所有提供者的后备资源。
根据上面的介绍,我们绘出新的ProvideManager 和AuthentictionProvider关系
总结: AuthenticationManager是认证管理器,在Spring Security中有全局AuthenticationManager,也可以有AuthenticationManager。全局的AuthenticationManager用来对全局认证进行处理,局部的AuthenticationManager用来对某些特殊资源认证处理。当然无论是全局认证管理器还是局部认证管理器都是由ProviderManger进行实现。每一个ProviderManger中都代理一个AuthenticationProvider的列表, 列表中每一个实现代表一种身份认证方式。认证时底层数据源需要调用UserDetailService实现
8.2:全局配置AuthenticationManager
我们可以先使用默认的InMemoryUserDetailsManager
实现类自定义一次基于内存的认证,在WebSecurityConfig中加入如下方法,就创建了一个李四用户,运行后经过测试可以知道,此时只有李四用户可以登录,默认的user用户和yml配置的用户都会失效
// springboot对security默认配置中,在工厂默认创建AuthenticationManager
@Autowired
public void customAuthenticationManager(AuthenticationManagerBuilder builder) throws Exception {
InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
// {noop}代表明文密码
userDetailsManager.createUser(User.withUsername("李四").password("{noop}123").roles("admin").build());
builder.userDetailsService(userDetailsManager);
}
在之前的Spring Security(一) —— 整体架构与入门案例分析
博客中我们已经分析过,在UserDetailsServiceAutoConfiguration类中,如果检测到有UserDetailsService实例,那么就不会创建自动InMemoryUserDetailsManager这个Bean,而是让AuthenticationManagerBuilder直接使用UserDetailsService实例
因此我们可以猜想,如果我们创建了UserDetailsService实例,也能替换默认的认证方式,可以注释掉刚才的customAuthticationManager方法(不注释会导致循环引用),并在WebSecurityConfig中加入如下方法,经过测试我们会发现,此时也是只有李四用户可以认证成功
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
userDetailsManager.createUser(User.withUsername("李四").password("{noop}123").roles("admin").build());
return userDetailsManager;
}
除了上述两个方法,我们还可以通过重写configure方法实现,如下所示,注意,一但重写configure,那么即使已经配置了上面两个方法,上面两种方法也会无效。一般来说,推荐使用这种重写configure的方式
// 自定义AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
System.out.println("自定义AuthenticationManager:" + auth);
InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
userDetailsManager.createUser(User.withUsername("李四").password("{noop}123").roles("admin").build());
auth.userDetailsService(userDetailsManager);
}
当然上面的演示数据源还是基于内存,而不是数据库,接下来就开始替换数据源
8.3:库表设计
我们可以知道,自定义数据源需要实现UserDetailsService接口,该接口只有一个loadUserByUsername()
方法,可知其返回的是一个UserDetails实现类对象,而我们只要返回它的一个实现类User的对象就可以了
public interface UserDetailsService {
// 通过用户名加载用户信息
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
User源码,仅看部分成员变量,可见Spring Security建议我们数据库应该要存在以下信息
public class User implements UserDetails, CredentialsContainer {
// 密码
private String password;
// 用户名
private final String username;
// 权限
private final Set<GrantedAuthority> authorities;
// 账号是否过期
private final boolean accountNonExpired;
// 账号是否锁定
private final boolean accountNonLocked;
// 密码是否过期
private final boolean credentialsNonExpired;
// 是否启用
private final boolean enabled;
}
建表sql如下:
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, 'ROLE_product', '商品管理员');
INSERT INTO `role` VALUES (2, 'ROLE_admin', '系统管理员');
INSERT INTO `role` VALUES (3, 'ROLE_user', '用户管理员');
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`enabled` tinyint(1) NULL DEFAULT NULL,
`accountNonExpired` tinyint(1) NULL DEFAULT NULL,
`accountNonLocked` tinyint(1) NULL DEFAULT NULL,
`credentialsNonExpired` tinyint(1) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, '张三', '{noop}123', 1, 1, 1, 1);
INSERT INTO `user` VALUES (2, '李四', '{noop}456', 1, 1, 1, 1);
INSERT INTO `user` VALUES (3, '王五', '{noop}789', 1, 1, 1, 1);
-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`id` int NOT NULL AUTO_INCREMENT,
`uid` int NOT NULL,
`rid` int NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user_role
-- ----------------------------
INSERT INTO `user_role` VALUES (1, 1, 1);
INSERT INTO `user_role` VALUES (2, 1, 2);
INSERT INTO `user_role` VALUES (3, 2, 2);
INSERT INTO `user_role` VALUES (4, 3, 3);
8.4:基于数据库的数据源实现
数据库准备好后就可以开始进行实现了,首先我们要配置mysql,这个就省略了,然后要提供两个entity:
RoleEntity.java:
@Data
public class RoleEntity {
private Integer id;
private String name;
private String nameZh;
}
UserEntity.java
@Data
public class UserEntity implements UserDetails {
private Integer id;
private String password;
private String username;
private List<RoleEntity> roles = new ArrayList<>();
private boolean accountNonExpired;
private boolean accountNonLocked;
private boolean credentialsNonExpired;
private boolean enabled;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<SimpleGrantedAuthority> authorities = new HashSet<>();
roles.forEach(role -> {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getName());
authorities.add(simpleGrantedAuthority);
});
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
有了实体类,还得有mapper执行sql查询:
@Mapper
public interface UserMapper {
/**
* 根据用户名获取用户信息
* @param username 用户名
* @return
*/
@Select("select id, username, password, enabled, accountNonExpired, accountNonLocked, credentialsNonExpired from user where username=#{username}")
UserEntity loadUserByUsername(String username);
/**
* 根据用户id查询用户角色
* @param uid 用户id
* @return
*/
@Select("select r.id, r.name, r.name_zh nameZh from role r, user_role ur where r.id=ur.rid and ur.uid=#{uid}")
List<RoleEntity> getRolesByUid(Integer uid);
}
然后就是实现UserDetailsService接口:
@Component
public class CustomUserDetailService implements UserDetailsService {
private final UserMapper userMapper;
public CustomUserDetailService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户
UserEntity user = userMapper.loadUserByUsername(username);
if(ObjectUtils.isEmpty(user)) throw new UsernameNotFoundException("用户名或账号错误");
// 查询权限信息
List<RoleEntity> roles = userMapper.getRolesByUid(user.getId());
user.setRoles(roles);
return user;
}
}
最后在WebSecurityConfig里指定加载我们实现的UserDetailService:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomUserDetailService customUserDetailService;
public WebSecurityConfig(CustomUserDetailService customUserDetailService) {
this.customUserDetailService = customUserDetailService;
}
// 自定义AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailService);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/hello1").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler(new CustomAuthenticationSuccessHandler())
.failureHandler(new CustomAuthenticationFailureHandler())
.and()
.logout()
.logoutSuccessHandler(new CustomLogoutSuccessHandler());
}
}
接下来就可以测试了,可见这么配置是成功的,已经成功将数据源替换成了数据库
如果有兴趣了解更多相关内容,欢迎来我的个人网站看看:瞳孔的个人空间