Spring Security学习笔记(二)Spring Security认证和鉴权

前言:本系列博客基于Spring Boot 2.6.x依赖的Spring Security5.6.x版本

上一篇博客介绍了Spring Security的整体架构,本篇博客要讲的是Spring Security的认证和鉴权两个重要的机制。

UsernamePasswordAuthenticationFilter和BasicAuthenticationFilter是用来认证的两个过滤器,FilterSecurityInterceptor是用来鉴权的。

一、Spring Security认证

Spring Security提供了许多认证机制,例如用户名密码认证、OAuth 2.0认证、SAML认证、Central Authentication Server (CAS)认证、Remember Me(记住过了session有效期的用户)、JAAS认证、X509认证等

1.1、认证架构

Spring Security认证架构主要由以下几个组件构成:
SecurityContext:Spring Security的上下文对象,包含了当前认证用户的Authentication(认证)。

SecurityContextHolder:用于设置和获取SecurityContext的静态工具类,保存了SecurityContext上下文对象。

Authentication:认证接口,定义了获取用户凭证、认证信息、权限等方法规范。

GrantedAuthority:权限类,用来定义用户的权限,Authentication中会保存一个GrantedAuthority类型的权限列表。

AuthenticationManager:认证管理器接口,只有一个authenticate方法,它的实现类实现该方法用来执行具体的认证逻辑,入参和出参都是Authentication。

ProviderManager:最常见的AuthenticationManager的实现。

AuthenticationProvider:认证功能提供者接口。在ProviderManager中实际上的认证逻辑由该接口的实现类处理。DaoAuthenticationProvider、AnonymousAuthenticationProvider都是它的实现类。

AuthenticationEntryPoint:用于从客户端请求凭证(即重定向到登录页面,返回需要登录响应等)。

AbstractAuthenticationProcessingFilter:一个用于认证的基本 Filter。是一个抽象类,只有UsernamePasswordAuthenticationFilter一个实现,UsernamePasswordAuthenticationFilter会从请求中获取username和 password参数,去进行认证。

1.1.1、SecurityContext

Spring Security的上下文对象,可以设置和获取Authentication认证信息。

java 复制代码
public interface SecurityContext extends Serializable {
	// 获取Authentication对象
    Authentication getAuthentication();
    // 放入Authentication对象
    void setAuthentication(Authentication authentication);
}

1.1.2、SecurityContextHolder

SecurityContextHolder是用来设置和获取SecurityContext的静态工具类,SecurityContextHolder不关心SecurityContext里认证信息的细节,即Authentication的具体实现类型是什么它并不关心,如果它能获取到值,这个值就认为是当前用户的认证信息。

java 复制代码
public class SecurityContextHolder {
	...
	//常用方法
    public static void clearContext() {
        strategy.clearContext();
    }
    
    public static SecurityContext getContext() {
        return strategy.getContext();
    }

    public static void setContext(SecurityContext context) {
        strategy.setContext(context);
    }
    ...
}

SecurityContextHolder架构图:

默认情况下,SecurityContextHolder使用ThreadLocal来存储这些细节,这意味着 SecurityContext 对同一线程中的方法总是可用的,即使SecurityContext没有被明确地作为参数传递给这些方法。并且Spring Security的FilterChainProxy会确保SecurityContext总是被清空,不用我们手动清空。

1.1.3、Authentication

Authentication是认证信息接口,定义了获取用户凭证、认证信息、权限等方法规范。它主要有两个作用,一是充当未认证的用户凭证(包括用户名、密码);一是表示验证后的认证信息(包括认证后用户信息、用户权限等)。Authentication一般包含了如下信息:

principal: 识别用户。当用用户名/密码进行认证时,这通常是 UserDetails 的一个实例。

credentials: 通常是一个密码。在许多情况下,这在用户被认证后被清除,以确保它不会被泄露。

authorities: GrantedAuthority 实例是用户被授予的权限。

java 复制代码
public interface Authentication extends Principal, Serializable {
	//获取用户权限,一般情况下获取到的是用户的角色信息
    Collection<? extends GrantedAuthority> getAuthorities();
    //获取证明用户认证的信息,通常情况下获取到的是密码等信息
    Object getCredentials();
    //获取用户的额外信息,(这部分信息可以是我们的用户表中的信息)
    Object getDetails();
    // 获取用户身份信息,在未认证的情况下获取到的是用户名,在已认证的情况下获取到的是 UserDetails
    Object getPrincipal();
    //获取当前 Authentication 是否已认证
    boolean isAuthenticated();
    //设置当前 Authentication 是否已认证(true or false)
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

1.1.4、GrantedAuthority

Spring Security定义的权限类规范接口,认证后的用户权限就是以GrantedAuthority类型的集合保存的。使用时分两种权限,分别是角色(role)和作用域(scope)。role类型的权限表示该权限为角色,角色可能会对应许多的具体资源(菜单、接口等)权限;scope表示某个具体资源的权限。一般使用role类型的权限,因为使用scope的话,认证时可能会保存有非常多的GrantedAuthority,容易导致内存不足,而role类型基本没有这种问题。注意设置role类型的权限时,权限最好加上ROLE_ 前缀,Spring Security默认的role类型鉴权方法会有ROLE_ 前缀。

java 复制代码
public interface GrantedAuthority extends Serializable {
	//拿到权限名
    String getAuthority();
}

1.1.5、AuthenticationManager

认证管理器接口,定义了执行认证逻辑的方法API。常用的实现类为ProviderManager。

java 复制代码
public interface AuthenticationManager {
	//用户执行认证时的方法,具体逻辑由实现类实现
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

1.1.6 、ProviderManager

ProviderManager是最常用的AuthenticationManager的实现。ProviderManager委托给一个 AuthenticationProvider集合。每个 AuthenticationProvider都有机会表明认证应该是成功的、失败的,或者表明它不能做出决定并允许下游的AuthenticationProvider来决定。如果配置的 AuthenticationProvider实例中没有一个能进行认证,那么认证就会以ProviderNotFoundException 而失败,这是一个特殊的AuthenticationException,表明ProviderManager没有被配置为支持被传入它的Authentication类型。

1.1.7、AuthenticationProvider

实际上执行认证逻辑的地方。常用的实习类DaoAuthenticationProvider(支持基于用户名/密码的认证)、AnonymousAuthenticationProvider(匿名用户认证)

java 复制代码
public interface AuthenticationProvider {
	//执行具体认证逻辑
    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    boolean supports(Class<?> authentication);
}

1.1.8、AuthenticationEntryPoint

如果用户访问一个需要认证后才能访问的资源,AuthenticationEntryPoint就会返回一个响应,需要用户先认证后或者携带认证凭证再访问。比如重定向到登录页面,或者返回一个携带"需要登录"提示的响应信息。我们可以实现该接口,自定义的未登录认证提示。Spring Security默认会对未认证去访问需要认证的资源的请求返回403。

1.1.9、AbstractAuthenticationProcessingFilter

用户认证的基础Filter,只有UsernamePasswordAuthenticationFilter这一个实现类。

AbstractAuthenticationProcessingFilter源码:

java 复制代码
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
   ...
   //主要方法
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    	//先校验请求url与表单校验提交的url是否一致,不一致执行下一个Filter
    	//一致的话就执行认证逻辑,一般默认的表单提交url是"/login"
        if (!this.requiresAuthentication(request, response)) {
            chain.doFilter(request, response);
        } else {
            try {
            	//实现类执行具体的认证逻辑
                Authentication authenticationResult = this.attemptAuthentication(request, response);
                if (authenticationResult == null) {
                    return;
                }

                this.sessionStrategy.onAuthentication(authenticationResult, request, response);
                if (this.continueChainBeforeSuccessfulAuthentication) {
                    chain.doFilter(request, response);
                }

                this.successfulAuthentication(request, response, chain, authenticationResult);
            } catch (InternalAuthenticationServiceException var5) {
                this.logger.error("An internal error occurred while trying to authenticate the user.", var5);
                this.unsuccessfulAuthentication(request, response, var5);
            } catch (AuthenticationException var6) {
                this.unsuccessfulAuthentication(request, response, var6);
            }

        }
    }
    //由子类实现具体的验证逻辑
    public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException;

	...
}

UsernamePasswordAuthenticationFilter的 attemptAuthentication() 方法

java 复制代码
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
        	//取用户名,实际上是从request取username参数
            String username = this.obtainUsername(request);
            username = username != null ? username : "";
            username = username.trim();
            //取密码,实际上是从request取password参数
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

AbstractAuthenticationProcessingFilter认证步骤:

1、当用户提交他们的凭证(用户名和密码)时,AbstractAuthenticationProcessingFilter会从HttpServletRequest中创建一个要认证的Authentication。创建的认证的类型取决于 AbstractAuthenticationProcessingFilter的子类。例如,UsernamePasswordAuthenticationFilter从HttpServletRequest中提交的username和password创建一个 UsernamePasswordAuthenticationToken。

2、接下来,Authentication被传入AuthenticationManager,执行认证逻辑。

3、如果认证失败,则为Failure。

  • SecurityContextHolder被清空。

  • RememberMeServices.loginFail被调用。如果没有配置记住我(remember me),可以忽略。

  • AuthenticationFailureHandler被调用。参考AuthenticationFailureHandler接口。

4、 如果认证成功,则为Success。

  • SessionAuthenticationStrategy被通知有新的登录。参考SessionAuthenticationStrategy接口。

  • Authentication是在SecurityContextHolder上设置的。如果你需要保存SecurityContext以便在未来的请求中自动设置,必须显式调用SecurityContextRepository#saveContext。参考 SecurityContextHolderFilter类。

  • RememberMeServices.loginSuccess 被调用。如果没有配置remember me,可以忽略。

  • ApplicationEventPublisher发布一个InteractiveAuthenticationSuccessEvent事件。

  • AuthenticationSuccessHandler被调用。参考AuthenticationSuccessHandler接口。

1.2、代码示例

1.2.1、默认登录认证

引入需要用到的相关包。

xml 复制代码
	<dependencies>
		<!-- 如果你项目的maven父工程是spring-boot-starter-parent包,可以不写版本号,springboot管理了版本号-->
		<!--Spring Security-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!--使用undertow容器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
        </dependency>
        
		<dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        
		<dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
	</dependencies>

定义一个controller

java 复制代码
@Controller
public class LoginController {
	//主页url
    @RequestMapping("/main")
    public String mainPage(){
        return "main";
    }
}

resource/templates/ 路径下里定义一个main.html作为主页

html 复制代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>主页面</title>
</head>
<body>
    <h1>主页面</h1>
    <form th:action="@{/logout}" method="post">
        <input type="submit" value="登出">
    </form>
</body>
</html>

application.yml

yaml 复制代码
server:
  port: 8084
  servlet:
    context-path: /security
    
spring:
  security:
  	#配置Spring Security默认登录用户和密码
  	#不配置的话,启动项目时,Spring Security会在控制台打印出默认密码,用户名是User
    user:
      name: User
      password: 123456

一切准备就绪,启动项目,访问localhost:8084/security/main,会自动重定向到Spring Security的默认登录页面。

这是因为Spring Security使用了默认的表单登录认证的方式。查看控制台打印信息,可以看到类似下面的输出。
如果没有,可能是Spring Security的版本问题,我使用的Spring Boot-2.6.2引入的Spring Security-5.6.2,关于这一块的打印信息逻辑写错了,导致未打印,可以将Spring Boot版本升级一下。

bash 复制代码
2023-06-14T08:55:22.321-03:00  INFO 76975 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]

UsernamePasswordAuthenticationFilter过滤器就是用来表单登录认证的Filter。

1.2.2、自定义登录页面

resource/templates/ 路径下里定义一个login.html作为登录页

html 复制代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
</head>
<body>
    <h1>登录页面</h1>
    <!--method必须为post-->
    <!--th:action="@{/login}",
    使用动态参数,表单中会自动生成_csrf隐藏字段,用于防止csrf攻击
    login:和登录页面保持一致即可,SpringSecurity自动进行登录认证
    /login 是Spring Security默认的登录认证路径,默认情况下用户名和密码名称必须是username和password
    -->
    <form th:action="@{/login}"  method="post">
        用户名:<input type="text" name="username"> <br>
        密码:<input type="password" name="password"><br>
        <input type="submit">
    </form>
</body>
</html>

LoginController添加登录页面跳转接口

java 复制代码
	@RequestMapping("/myLoginPage")
    public String myLoginPage(){
        return "login";
    }

自定义Spring Security的配置类

java 复制代码
@Configuration
public class BasicSecurityConfig {
    
    @Bean
    public SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
        // 登录相关配置
        http.formLogin(formLogin -> formLogin
                .loginPage("/myLoginPage") // 自定义登录页面,不再使用内置的自动生成页面
                //登录认证接口url,这里可以任意设置,只要保证和登录表单提交的url相同即可
                .loginProcessingUrl("/login")
                .usernameParameter("username")// 表单中的用户名项
                .passwordParameter("password")// 表单中的密码项
                .successForwardUrl("/main")//登录成功后跳转的路径,未设置会跳转到项目根路径
        );
        //设置访问权限,如果不设置,默认所有的url都可以匿名访问
        http.authorizeRequests(authorize ->{authorize
                .antMatchers("/myLoginPage").permitAll() //允许所有用户访问
                .anyRequest()   //对所有请求开启授权保护
                .authenticated(); //已认证的请求会被自动授权
        });
        http.logout(logout ->logout
                .logoutUrl("/logout") //使用该方法时,当开启csrf防护,logout请求必须是post,否则会404
                .clearAuthentication(true) //清除认证状态,默认为true
                .invalidateHttpSession(true) // 销毁HttpSession对象,默认为true
        );
        //关闭csrf防护,否则所有的POST的请求都需要携带CSRF令牌
        http.csrf(csrf -> csrf.disable());
        return http.build(); // 返回构建的SecurityFilterChain实例
    }
}

还有一种写法是继承WebSecurityConfigurerAdapter类,重写configure方法,但是Spring Security 6.0及之后的版本删除了WebSecurityConfigurerAdapter类,不能用这种写法配置了。

java 复制代码
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
    public void configure(HttpSecurity http) throws Exception {
    	//和上面配置相同,最后无需调用http.build()方法
        ....
    }
}

注意:使用表单登录认证时,实际处理认证的是UsernamePasswordAuthenticationFilter类,loginProcessingUrl方法配置的url可以任意配置,只要和登录表单提交的url相同即可。

1.2.3、自定义Handler逻辑

Spring Security定义了一些Handler接口,让我们可以自定义认证结束后的处理逻辑。比如返回JSON结果,适用于前后端分离的项目。

1.2.3.1、认证成功处理

AuthenticationSuccessHandler类是Spring Security提供的认证成功后处理逻辑接口。

实现AuthenticationSuccessHandler接口:

java 复制代码
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        //  获取用户身份信息
        UserDetails userDetails = (UserDetails)authentication.getPrincipal();
        //  获取用户的凭证信息
        Object credentials = authentication.getCredentials();
        //  获取用户权限信息
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        /*返回页面,适用于前后端未分离的项目*/
        System.out.println("用户名:"+userDetails.getUsername());
        System.out.println("一些操作...");
        //response.sendRedirect(request.getContextPath()+"/main");

        /*返回json,适用于前后端分离*/
        //这里可以生成token,并存redis等
        Map<String,Object> result = new HashMap();
        result.put("code",0);   // 成功
        result.put("message","登录成功");   //
        result.put("data",userDetails);   //这里可以换成token,jwt等登录成功凭证
        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);
        //  返回json数据到前端
        //  响应头
       response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);
    }
}

在BasicSecurityConfig配置类的formLogin中加上MyAuthenticationSuccessHandler

java 复制代码
		// 登录相关配置
        http.formLogin(formLogin -> formLogin
                .loginPage("/myLoginPage") // 自定义登录页面,不再使用内置的自动生成页面
                //登录认证接口url,这里可以任意设置,只要保证和登录表单提交的url相同即可
                .loginProcessingUrl("/login")
                .usernameParameter("username")// 表单中的用户名项
                .passwordParameter("password")// 表单中的密码项
                .successForwardUrl("/main")//登录成功后跳转的路径,未设置会跳转到项目根路径
                .successHandler(new MyAuthenticationSuccessHandler()) //认证成功处理
        );
1.2.3.2、认证失败处理

AuthenticationFailureHandler类是Spring Security提供的认证失败处理逻辑接口。

实现AuthenticationFailureHandler接口:

java 复制代码
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        //  获取失败的信息
        String localizedMessage = exception.getLocalizedMessage();

        Map<String,Object> result = new HashMap();
        result.put("code",-1);   // 失败
        result.put("message",localizedMessage);   //
        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);
        //  返回json数据到前端
        //  响应头
        response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);

        //重定向到登录错误页面,适用与前后端不分离项目
        //response.sendRedirect(request.getContextPath()+"/loginError");
    }
}

在BasicSecurityConfig配置类的formLogin中加上MyAuthenticationFailureHandler

java 复制代码
		// 登录相关配置
        http.formLogin(formLogin -> formLogin
        		...
                .successHandler(new MyAuthenticationSuccessHandler()) //认证成功处理
                .failureHandler(new MyAuthenticationFailureHandler()) //认证失败处理
        );
1.2.3.3、登出成功处理

LogoutSuccessHandler类是Spring Security提供的登出成功处理逻辑接口。

实现LogoutSuccessHandler接口:

java 复制代码
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //  获取用户身份信息
        UserDetails userDetails = (UserDetails)authentication.getPrincipal();

        /** 返回json,适用于前后端分离*/
        Map<String,Object> result = new HashMap();
        result.put("code",1);   // 成功
        result.put("message","注销成功");   //
        result.put("data",userDetails);   //
        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);

        //  返回json数据到前端 适用前后端分离
        //  响应头
        response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);

        //返回到页面
        //response.sendRedirect(request.getContextPath()+"/main");
    }
}

在BasicSecurityConfig配置类的logout中加上MyLogoutSuccessHandler

java 复制代码
http.logout(logout ->logout
                .logoutUrl("/logout") //使用该方法时,当开启csrf防护,logout请求必须是post,否则会404
                .clearAuthentication(true) //清除认证状态,默认为true
                .invalidateHttpSession(true) // 销毁HttpSession对象,默认为true
                .logoutSuccessHandler(new MyLogoutSuccessHandler())
        );
1.2.3.4、请求未认证资源处理

AuthenticationEntryPoint类是Spring Security提供的未认证访问资源处理逻辑接口。

实现AuthenticationEntryPoint类:

java 复制代码
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String localizedMessage = "需要登录";//authException.getLocalizedMessage();

        Map<String,Object> result = new HashMap();
        result.put("code",-1);   // 告诉用户需要登录
        result.put("message",localizedMessage);   //


        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);
        //  返回json数据到前端
        //  响应头
        response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);
        //返回登录界面
        //response.sendRedirect(request.getContextPath()+"/myLoginPage");
    }
}

在BasicSecurityConfig配置类的中加上配置

java 复制代码
		//异常处理
        http.exceptionHandling(exception -> exception
                .authenticationEntryPoint(new MyAuthenticationEntryPoint()) //请求未认证的处理
        );

1.2.4、基于数据库的认证

前面的示例中,我们的登录用户是写在配置文件里的,用的是基于内存存储用户信息的方式。这只能在学习时使用,在实际项目中是不行的。实际项目中,我们的用户信息时存在数据库里的,Spring Security也提供了基于数据库来进行认证的方式。

前文我们已经说过,通过HttpSecurity的formLogin方法配置的认证,是使用UsernamePasswordAuthenticationFilter类来进行的认证处理,而实际上处理时,是在ProviderManager的authenticate方法里,再调用DaoAuthenticationProvider的authenticate方法处理的。最终的处理是在DaoAuthenticationProvider类的父类AbstractUserDetailsAuthenticationProvider类的authenticate处理的。

而在进行认证前,需要先根据用户名查询系统里的用户数据(内存或数据库),再根据查询到的用户密码与用户输入的密码校验,校验通过,则认证成功。这一块的逻辑是由DaoAuthenticationProvider类重写父类的retrieveUser实现的。源码如下:

java 复制代码
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        this.prepareTimingAttackProtection();

        try {
        	//拿到用户信息
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            } else {
                return loadedUser;
            }
        } catch (UsernameNotFoundException var4) {
            this.mitigateAgainstTimingAttack(authentication);
            throw var4;
        } catch (InternalAuthenticationServiceException var5) {
            throw var5;
        } catch (Exception var6) {
            throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
        }
    }

通过调用UserDetailsService的loadUserByUsername方法,返回系统的用户信息。我们可以通过实现自己的UserDetailsService实现类,重写loadUserByUsername方法,查询数据库里的用户数据。代码如下:

java 复制代码
public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
    /**
     * UserDetails提供的字段如果不够的话,可以继承 User类,实现自己的UserDetails
     * 用户认证时会调用
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //根据userName去数据库查询用户信息, 伪代码
        UserDomain user = userService.queryUserByUserName(username);
        if(user == null){
            throw new UsernameNotFoundException(username);
        }
        //查询用户的角色,伪代码
        List<String> roles = roleService.getRoleCodeByUserId(user.getId());
        
        UserDetails userDetails = User.withUsername(user.getLoginName())
                .password(user.getPassword())
                //.authorities(roles.toArray(new String[roles.size()]))  //权限,和roles配一个就行,这里配置不会加前缀
                .roles(roles.toArray(new String[roles.size()])) //角色 配置角色时,会给资源自动加上ROLE_前缀
                .build();
        return userDetails;
    }

    @Override
    public void createUser(UserDetails user) {

    }

    @Override
    public void updateUser(UserDetails user) {

    }

    @Override
    public void deleteUser(String username) {

    }

    @Override
    public void changePassword(String oldPassword, String newPassword) {

    }

    @Override
    public boolean userExists(String username) {
        return false;
    }

    @Override
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        return null;
    }
}

然后在配置类中加上相关配置:

java 复制代码
@Configuration
public class BasicSecurityConfig {
	...
	
	/**
     * 密码编码器,会对请求传入的密码进行加密
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        //return NoOpPasswordEncoder.getInstance();
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService userDetailsService(){
        return new DBUserDetailsManager();
    }
    
    ...
}	

需要加一个密码编码器,使用Spring Security提供的默认编码器就行,使用编码器后,注意数据库保存的密码应该是密文。直接将我们的UserDetailsService注入到Spring容器中即可生效。

二、Spring Security鉴权

2.1、鉴权架构

2.1.1、FilterSecurityInterceptor

Spring Security进行鉴权处理的入口。父类是AbstractSecurityInterceptor类

2.1.2、AccessDecisionManager

Spring Security鉴权的真正处理者

java 复制代码
public interface AccessDecisionManager {
	//鉴权方法 
	/**
	* authentication 当前用户的认证凭证信息,包括了用户信息,权限等
	* object 一般是FilterInvocation,包含了当前请求的request和response
	* configAttributes过滤规则,由配置类里的 HttpSecurity的authorizeRequests方法配置
	*/
    void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException;
    //是否允许AccessDecisionManager处理该过滤规则,true为允许
    boolean supports(ConfigAttribute attribute);
	//是否允许AccessDecisionManager处理clazz类型,true为允许
    boolean supports(Class<?> clazz);
}

Spring Security的鉴权是基于投票机制的鉴权方式。

2.1.3、AccessDecisionVoter

投票器,AccessDecisionManager的投票处理是由AccessDecisionVoter投票器决定的,一个AccessDecisionManager里会包含一个AccessDecisionVoter集合,AccessDecisionManager会根据所有投票器的投票结果来决定请求是否有权访问,无权限会抛出一个 AccessDeniedException。

java 复制代码
public interface AccessDecisionVoter<S> {
	//同意
    int ACCESS_GRANTED = 1;
    //弃权
    int ACCESS_ABSTAIN = 0;
    //反对
    int ACCESS_DENIED = -1;

    boolean supports(ConfigAttribute attribute);
	
    boolean supports(Class<?> clazz);
    //投票方法
    int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}

AccessDecisionManager有三个实现类

  • AffirmativeBased:一票通过,只要有一票通过就算通过,默认是它。
  • UnanimousBased:一票反对,只要有一票反对就不能通过。
  • ConsensusBased:少数票服从多数票。

2.2、代码示例

2.2.1、默认鉴权

定义两个接口,分别由两种权限访问。在LoginController中新增

java 复制代码
	//admin权限
	@RequestMapping("/adminRole")
    @ResponseBody
    public String adminRole(){
        return "success";
    }
	//tourist权限
    @RequestMapping("/touristRole")
    @ResponseBody
    public String touristRole(){
        return "success";
    }

在BasicSecurityConfig配置类中新增这两个接口鉴权配置:

java 复制代码
	@Bean
    public SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
    	//和前文一样的配置省略了
        ...
        //设置访问权限,如果不设置,默认所有的url都可以匿名访问
        http.authorizeRequests(authorize ->{authorize
                // 放行所有OPTIONS请求
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .antMatchers("/myLoginPage").permitAll() //登录页面允许所有用户访问
                .antMatchers("/adminRole").hasRole("AdminManager")	// /adminRole 只能AdminManager角色访问
                .antMatchers("/touristRole").hasAnyRole("AdminManager","ApproveUser")	// /touristRole AdminManager和ApproveUser角色都能访问
                .anyRequest()   //对所有请求开启授权保护
                .authenticated(); //已认证的请求会被自动授权
        });
       	...
        return http.build(); // 返回构建的SecurityFilterChain实例
    }

通过给"/adminRole"和"/touristRole"接口配置权限过滤规则,用户访问接口时,就会在登录认证成功后,在SecurityContext上下文中设置凭证信息,其中就包括当前用户的权限,然后匹配配置的权限过滤规则,判断当前用户是否有该接口的权限。如果不配置权限过滤规则,则默认认证成功的用户都可以访问。

前文说过,在进行表单登录认证时,Spring Security是通过调用UserDetailsService的loadUserByUsername方法,得到当前登录用户的信息的,其中就包括权限信息。

java 复制代码
 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //根据userName去数据库查询用户信息, 伪代码
        UserDomain user = userService.queryUserByUserName(username);
        if(user == null){
            throw new UsernameNotFoundException(username);
        }
        //查询用户的角色,伪代码
        List<String> roles = roleService.getRoleCodeByUserId(user.getId());
        
        UserDetails userDetails = User.withUsername(user.getLoginName())
                .password(user.getPassword())
                //.authorities(roles.toArray(new String[roles.size()]))  //权限,和roles配一个就行,这里配置不会加前缀
                .roles(roles.toArray(new String[roles.size()])) //角色 配置角色时,会给资源自动加上ROLE_前缀
                .build();
        return userDetails;
    }

通过Spring Security的User类的roles和authorities方法,就可以设置当前登录用户的权限信息。这里需要注意的是,如果配置权限过滤规则时,使用的是role(角色)权限,loadUserByUsername方法也得设置role权限,反之亦然。权限名称相同即可。

2.2.2、请求未授权接口处理

Spring Security定义了AccessDeniedHandler接口,用来处理访问未授权接口的请求。只需实现AccessDeniedHandler接口,然后将自定义的类加入到配置里即可。

java 复制代码
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Map<String,Object> result = new HashMap();
        result.put("code",-1);   // 没有权限
        result.put("message","没有权限");   //


        //  将结果对象转换成json字符串
        String json = JSON.toJSONString(result);

        //  返回json数据到前端
        //  响应头
        response.setContentType("application/json;charset=UTF-8");
        //  响应体
        response.getWriter().println(json);
        //返回页面
        //response.sendRedirect(request.getContextPath()+"/main");
    }
}

在BasicSecurityConfig配置类中加上该类

java 复制代码
	@Bean
    public SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
       ...
        //异常处理
        http.exceptionHandling(exception -> exception
                .authenticationEntryPoint(new MyAuthenticationEntryPoint()) //请求未认证的处理
                .accessDeniedHandler(new MyAccessDeniedHandler())   //未授权处理
        );
        //关闭csrf防护,否则所有的POST的请求都需要携带CSRF令牌
        http.csrf(csrf -> csrf.disable());
        return http.build(); // 返回构建的SecurityFilterChain实例
    }

2.2.3、基于方法注解的方式鉴权

Spring Security提供了基于注解的方式,设置权限过滤规则的方法。具体使用如下:

使用@EnableMethodSecurity注解开启基于方法的授权,在自定义的BasicSecurityConfig配置类上加上即可

java 复制代码
@Configuration
@EnableMethodSecurity
public class BasicSecurityConfig {
	...
}

然后在Controller的方法上使用@PreAuthorize注解即可。首先在配置类里去掉"/adminRole"和"/touristRole"的权限过滤规则配置。然后在LoginController里给这两个接口加上@PreAuthorize注解:

java 复制代码
	@RequestMapping("/adminRole")
    @ResponseBody
    @PreAuthorize("hasAnyRole('AdminManager')")
    public String adminRole(){
        return "success";
    }

    @RequestMapping("/touristRole")
    @ResponseBody
    @PreAuthorize("hasAnyRole('AdminManager','ApproveUser')")
    public String touristRole(){
        return "success";
    }

@PreAuthorize里可以使用SpEL表达式,例如:hasRole('ADMIN') and authentication.name == 'User' 这种。可以使用的规则如下:

具体可以参考Spring Security关于这一块的官网介绍:

https://springdoc.cn/spring-security/servlet/authorization/authorize-http-requests.html#authorization-expressions

相类似的注解还有@PostAuthorize、@PreFilter、@PostFilter等。具体的用法也可以去官网查找。

相关推荐
立志成为大牛的小牛1 分钟前
数据结构——三十一、最小生成树(王道408)
数据结构·学习·程序人生·考研·算法
.柒宇.11 分钟前
《云岚到家》第一章个人总结
spring boot·spring·spring cloud
摇滚侠21 分钟前
Spring Boot3零基础教程,Actuator 导入,笔记82
java·spring boot·笔记
lang2015092831 分钟前
Spring XML AOP配置实战指南
xml·java·spring
WarPigs34 分钟前
Blender动画笔记
笔记·blender
جيون داد ناالام ميづ1 小时前
Spring事务原理探索
java·后端·spring
艾菜籽1 小时前
MyBatis动态sql与留言墙联系
java·数据库·sql·spring·mybatis
Every exam must be1 小时前
10.27 JS学习12
开发语言·javascript·学习
崎岖Qiu1 小时前
【设计模式笔记11】:简单工厂模式优缺分析
java·笔记·设计模式·简单工厂模式
2501_938176881 小时前
远期合约和期权合约的区别是什么?
笔记