【Spring Security从入门到进阶】(一)什么是Spring Security | Eddie'Blog
【Spring Security从入门到进阶】(一)什么是Spring Security

【Spring Security从入门到进阶】(一)什么是Spring Security

eddie 354 2021-09-20

[toc]

目录

Spring Security 之后会学习章节迁移到
CSDN - Spring Security 专栏

(一)前置要求

  • 掌握 Spring 框架
  • 掌握 SpringBoot 使用
  • 掌握 Java Web 技术
  • 安装 JDK8、Maven、IDEA、Nodejs

(二)什么是Spring Security

2.1.1 简述认证与授权

2.1.1.1 认证

添加Maven依赖

 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
 </dependency>

自定义一个控制层的请求,测试一下~

比如:http://localhost:8080/api/sayHello 就会跳转到 http://localhost:8080/login

在这里插入图片描述

username:user
password:后台打印的“Using generated security password: xxxxxxx”

2.1.1.2 授权

每次登录都需要登录

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 默认是没有ADMIN, 访问该路径就返回403
        http.authorizeRequests(
                req -> req.mvcMatchers("/api/sayHello")
                        .hasRole("ADMIN")
        );
}

只需要登录一次就可以访问

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .formLogin(Customizer.withDefaults())
            .authorizeRequests(
                    req -> req.mvcMatchers("/api/sayHello")
                    .authenticated()
            );
    }
}

2.1.2 Spring过滤器

  • 任何Spring Web 应用本质上只有一个Servlet
  • Security Filter在Http请求到达Controller之前过滤每一个传入的Http请求
graph LR A[浏览器] --> B[Security Filter -Tomcat] B --> C C[检查用户是否已认证] --是--> D(DispatcherServlet) C --否--> E(HTTP 401/403) D --> F[RestController / Controller]

2.1.3 Spring Security的过滤器

过滤器说明
BasicAuthenticationFilter如果在请求中找到一个Basic Auth HTTP头,如果找到,则尝试用该头中的用户名和密码验证用户
UsernamePasswordAuthenticationFilter如果在请求参数或者 POST 的 Request Body 中找到用户名 / 密码,则尝试用这些值对用户进行身份验证
DefaultLoginPageGeneratingFilter默认登录页面生成过滤器,用于生成登录页面,如果你没有明确地禁用这个功能,那么就会生成一个登录。这就是为什么在启动 Spring Secuity 时,会得到一个默认登录页面的原因
DefaultLogoutPageGeneratingFilter如果没有禁用该功能,则会生成一个注销页面
FilterSecurityInterCeptor过滤安全拦截器,用于授权逻辑
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	// 不重写任何方法,留空
}

当访问 http://localhost:8080/ 的时候, 会跳转到 http://localhost:8080/login

星期六 17:14:11.838 DEBUG 20300 --- [nio-8080-exec-1] FilterChainProxy                         : Securing GET /
星期六 17:14:11.848 DEBUG 20300 --- [nio-8080-exec-1] SecurityContextPersistenceFilter         : Set SecurityContextHolder to empty SecurityContext
星期六 17:14:11.851 DEBUG 20300 --- [nio-8080-exec-1] AnonymousAuthenticationFilter            : Set SecurityContextHolder to anonymous SecurityContext
星期六 17:14:11.852 DEBUG 20300 --- [nio-8080-exec-1] SessionManagementFilter                  : Request requested invalid session id E37154E1182B059CAB9213F5754A9C38
星期六 17:14:11.857 DEBUG 20300 --- [nio-8080-exec-1] FilterSecurityInterceptor                : Failed to authorize filter invocation [GET /] with attributes [authenticated]
星期六 17:14:11.869 DEBUG 20300 --- [nio-8080-exec-1] HttpSessionRequestCache                  : Saved request http://localhost:8080/ to session
星期六 17:14:11.870 DEBUG 20300 --- [nio-8080-exec-1] DelegatingAuthenticationEntryPoint       : Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@4d65a9af, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
星期六 17:14:11.870 DEBUG 20300 --- [nio-8080-exec-1] DelegatingAuthenticationEntryPoint       : Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@859cf57
星期六 17:14:11.871 DEBUG 20300 --- [nio-8080-exec-1] DefaultRedirectStrategy                  : Redirecting to http://localhost:8080/login
星期六 17:14:11.871 DEBUG 20300 --- [nio-8080-exec-1] HttpSessionSecurityContextRepository     : Did not store empty SecurityContext

2.1.4 HTTP 请求

2.1.4.1 编写控制层

/**
 * @author eddie.lee
 * @blog blog.eddilee.cn
 */
@RestController
@RequestMapping("/api")
public class UserController {

	@GetMapping("/sayHello")
	public String sayHello() {
		return "Hello World";
	}

	@PostMapping("/sayHello")
	public String addSayHello(@RequestParam String name) {
		return "Hello " + name;
	}

	@PutMapping("/sayHello/{name}")
	public String updateSayHello(@PathVariable String name) {
		return "Hello " + name;
	}

	@PostMapping("/jsonSayHello")
	public String jsonSayHello(@RequestParam String name, @RequestBody Address address) {
		return "Hello " + name + " \n省: " + address.getSheng() + " \n市: " + address.getShi();
	}

	@Data
	static class Address {
		private String sheng;
		private String shi;
	}
}

2.1.4.2 PostMan测试

在测试之前需要 csrf 失效

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf(c -> c.disable())
                .httpBasic(Customizer.withDefaults())
                .formLogin(f -> f.loginPage("/"));
                
				// 选其一中即可
//        http.
//                authorizeRequests()
//                    .anyRequest()
//                    .authenticated()
//                .and()
//                    .httpBasic()
//                .and()
//                    .csrf().disable();
    }
}

抽取其中一个请求 “/api/jsonSayHello?name=eddie.lee” 测试
在这里插入图片描述

2.1.5 HTTP Basic Auth 认证流程 (UML)

sequenceDiagram 客户端 ->> 服务器: GET /sayHello HTTP/1.1 服务器-->> 客户端: HTTP1.1 401 Unauthorized <br> WWW-Authenticate:Basic realm="Realm" 客户端 ->> 客户端: 让用户填写 Username 和 Password 客户端 ->> 服务器: GET /sayHello HTTP/1.1 <br> Authentication:Basic dXNlcjplOTMyOWFiZC1iZDM4LTQ0MTQtYWRlNS0wMDEzMTk0N2Q4Y2M= 服务器->> 服务器: 检查 Username 和 Password 服务器-->>客户端: HTTP/1.1 200 OK



模拟第一条虚线:服务器到客户端出现 401

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf(c -> c.disable())
                .httpBasic(Customizer.withDefaults())
//                .formLogin(f -> f.loginPage("/"))
                // 浏览器自带表单
                .formLogin(f -> f.disable())
                // 需要授权请求的路径
                .authorizeRequests(r -> r.antMatchers("/api/**")
                        .authenticated()
                );
    }
}

不使用 Authorization(Basic Auth)
在这里插入图片描述
不使用 Security表单,使用浏览器自带的

在这里插入图片描述

2.1.6 Spring Security 配置

2.1.6.1 继承 WebSecurityConfigurerAdapter

继承 WebSecurityConfigurerAdapter 就会有效果吗? 有的~

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

传统方式 VS 函数式

/**
 * @author eddie.lee
 * @blog blog.eddilee.cn
 * @description (debug = true) 请勿在生产系统中使用
 */
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	// 在父类的352行
	@Override
    protected void configure(HttpSecurity http) throws Exception {
	    // 传统写法
//        http
//                // 认证授权
//                .authorizeRequests()
//                .antMatchers("/api/**")
//                .hasRole("USER")
//                .anyRequest()
//                .authenticated()
//                    .and()
//                // 表单
//                .formLogin()
//                .loginPage("/login")
//                .usernameParameter("username1")
//                    .and()
//                .httpBasic()
//                .realmName("BA");

        // 按源码的函数式风格,比较直观,每个模块分开,省略 .and()
        http
                .authorizeRequests(r -> r.anyRequest().authenticated())
                .formLogin(f -> f.disable())
                .httpBasic(Customizer.withDefaults())
                .csrf(c -> c.disable());
    }
}

打开 debug = true 之后,就可以查看到请求的

************************************************************

Request received for GET '/api/sayHello':

org.apache.catalina.connector.RequestFacade@3aaf881d

servletPath:/api/sayHello
pathInfo:null
headers: 
host: localhost:8080
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:92.0) Gecko/20100101 Firefox/92.0
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
accept-language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
accept-encoding: gzip, deflate
connection: keep-alive
cookie: JSESSIONID=F2471A465EF3E7962D95277DB4BEFA3D
upgrade-insecure-requests: 1
sec-fetch-dest: document
sec-fetch-mode: navigate
sec-fetch-site: none
sec-fetch-user: ?1
pragma: no-cache
cache-control: no-cache
authorization: Basic dXNlcjplOTMyOWFiZC1iZDM4LTQ0MTQtYWRlNS0wMDEzMTk0N2Q4Y2M=


Security filter chain: [
  WebAsyncManagerIntegrationFilter
  SecurityContextPersistenceFilter
  HeaderWriterFilter
  LogoutFilter
  BasicAuthenticationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  FilterSecurityInterceptor
]

************************************************************

2.1.6.2 配置 security 账户密码

不用每次重启都要修改,当然这配置只是演示。

spring:
  application:
    name: spring-security-start
  security:
    user:
      name: eddie
      password: 123456
      roles: USER,ADMIN

2.1.6.3 忽略图片,音频等静态资源

    /**
     * 忽略指定${图片,音频,其他静态资源},不走 Security 过滤器检查
     * 会打印:Security filter chain: [] empty (bypassed by security='none')
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/css/**");
    }

2.1.7 自定义登录页面(无兴趣可以忽略)

2.1.7.1 添加依赖

<properties>
    <bootstrap.version>4.5.0</bootstrap.version>
</properties>
 
<dependencies>
     <dependency>
         <groupId>org.webjars</groupId>
         <artifactId>bootstrap</artifactId>
         <version>${bootstrap.version}</version>
     </dependency>
     <dependency>
         <groupId>org.webjars</groupId>
         <artifactId>webjars-locator-core</artifactId>
     </dependency>
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
     </dependency>
     <!-- 官方地址:https://www.thymeleaf.org/ -->
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-thymeleaf</artifactId>
     </dependency>
</dependencies>

2.1.3.2 准备 login.html


点击查看
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <meta
            name="viewport"
            content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <title th:text="#{login.page.title}"></title>
    <link
            rel="stylesheet"
            type="text/css"
            href="/webjars/bootstrap/css/bootstrap.min.css"
            th:href="@{/webjars/bootstrap/css/bootstrap.min.css}"
    />
</head>
<body>
<div class="container">
    <form name="f" th:action="@{/login}" method="post">
        <div
                th:if="${param.error}"
                class="alert alert-danger"
                th:text="#{login.page.bad-credential}"
        >
            Invalid username and password.
        </div>
        <div
                th:if="${param.logout}"
                class="alert alert-success"
                th:text="#{login.page.logout.msg}"
        >
            You have been logged out.
        </div>
        <div class="form-group">
            <label for="username" th:text="#{login.page.form.username}"
            >Username:</label
            >
            <input
                    type="text"
                    class="form-control"
                    id="username"
                    name="username"
            />
        </div>
        <div class="form-group">
            <label for="password" th:text="#{login.page.form.password}"
            >Password:</label
            >
            <input
                    type="password"
                    class="form-control"
                    id="password"
                    name="password"
            />
        </div>
        <div class="form-check">
            <input
                    type="checkbox"
                    class="form-check-input"
                    id="remember-me"
                    name="remember-me"
            />
            <label class="form-check-label" for="remember-me" th:text="#{login.page.form.remember-me}"
            >Remember Me:</label
            >
        </div>
        <input
                type="hidden"
                id="csrf_token"
                th:name="${_csrf.parameterName}"
                th:value="${_csrf.token}"
        />
        <button
                type="submit"
                class="btn btn-primary"
                th:text="#{login.page.form.submit}"
        >
            Submit
        </button>
    </form>
</div>
<script src="/webjars/jquery/jquery.min.js" th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script src="/webjars/bootstrap/js/bootstrap.min.js" th:src="@{/webjars/bootstrap/js/bootstrap.min.js}"></script>
</body>
</html>

2.1.7.3 实现页面国际化

快捷键:Alt + Insert - Resource Bundle
鼠标点击:Menu- File - New - Resource Bundle
在这里插入图片描述


在这里插入图片描述
src/main/resources/messages.properties

login.page.title=Login
login.page.logout.msg=You have logged out.
login.page.bad-credential=Username or password is wrong
login.page.form.username=Username
login.page.form.password=Password
login.page.form.submit=Login
index.page.menu.sign-out=Sign Out
login.page.form.remember-me=Remember me

src/main/resources/messages_en.properties

login.page.title=Login
login.page.logout.msg=You have logged out.
login.page.bad-credential=Username or password is wrong
login.page.form.username=Username
login.page.form.password=Password
login.page.form.submit=Login
index.page.menu.sign-out=Sign Out
login.page.form.remember-me=Remember me

src/main/resources/messages_zh_CN.properties

login.page.title=登录
login.page.logout.msg=您已退出登录
login.page.bad-credential=用户名或密码不正确
login.page.form.username=用户名
login.page.form.password=密码
login.page.form.submit=登录
index.page.menu.sign-out=退出登录
login.page.form.remember-me=记住我

application.yml

spring:
  messages:
    basename: messages
    encoding: UTF-8

文件路径截图
在这里插入图片描述

2.1.7.4 修改 Security 配置

src/main/java/com/example/edcode/config/SecurityConfig.java

/**
 * @author eddie.lee
 * @blog blog.eddilee.cn
 */
// (debug = true) 请勿在生产系统中使用
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	// 在父类的352行
	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 指定路径需要认证
//                .authorizeRequests(r -> r.antMatchers("/api/**").authenticated())
                // 所有路径都需要认证
                .authorizeRequests(r -> r.anyRequest().authenticated())
                // 不添加 permitAll() 的话,就会一直“重定向”
                .formLogin(f -> f.loginPage("/login")
//                        .usernameParameter("username1")  // 指定username参数为username1,但需要前端配合传参也是 username1
                        .permitAll())
                .httpBasic(Customizer.withDefaults())
                .csrf(Customizer.withDefaults());

    }

    /**
     * 忽略指定${图片,音频,其他静态资源},不走 Security 过滤器检查
     * 会打印:Security filter chain: [] empty (bypassed by security='none')
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/css/**")
                // 会自动查找到 css, js 哪些静态资源在哪里
        .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

src/main/java/com/example/edcode/config/WebMvcConfig.java

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 映射
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("/webjars/")
                .resourceChain(false);
        registry.setOrder(1);
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 访问 login 就会访问这个视图
        registry.addViewController("/login").setViewName("login");
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }
}

2.1.7.5 浏览器访问

登录后,页面返回 404. 因为没有指定成功后跳转页面。
在这里插入图片描述

2.1.8 CSRF 攻击

2.1.8.1 场景描述

  1. 黑客发送恶意连接地址
  2. 用户打开了恶意连接地址
  3. 用户输入了数据的同时,也把提交的数据发送到恶意站点
  4. 最后就会通过恶意站点发送到正常站点

必要前提是用户已经登陆正常站点

2.1.8.2 抵御CSRF 攻击的方法

  • CSRF Token
    • 通过服务端生成Token存在Session,发送客户端保存到Cookie,下次客户端返回Token时候在校验
  • 在响应中设置 Cookie 的SameSite 属性(有浏览器限制)
http.csrf()

2.1.9 Logout 功能

index.html 点击查看
<!DOCTYPE html>
<html
        lang="zh"
        xmlns="http://www.w3.org/1999/xhtml"
        xmlns:th="https://www.thymeleaf.org"
        xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
>
<head>
    <title>Spring Security Mooc Course</title>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
    <link
            rel="stylesheet"
            href="/webjars/bootstrap/css/bootstrap.min.css"
            th:href="@{/webjars/bootstrap/css/bootstrap.min.css}"
    />
</head>
<body>
<nav class="navbar navbar-expand-sm bg-light">
    <!-- Links -->
    <ul class="navbar-nav">
        <li class="nav-item dropdown">
            <a
                    id="user-menu"
                    href="#"
                    class="dropdown-toggle"
                    data-toggle="dropdown"
                    role="button"
                    aria-haspopup="true"
                    aria-expanded="false"
            >
                <span sec:authentication="name">User</span>
            </a>
            <ul class="dropdown-menu">
                <li>
                    <a
                            id="sign-out"
                            href="javascript:document.logoutForm.submit()"
                            th:text="#{index.page.menu.sign-out}"
                    >Sign Out</a
                    >
                </li>
            </ul>
            <form name="logoutForm" th:action="@{/perform_logout}" method="post" th:hidden="true">
                <input hidden type="submit" value="Sign Out" />
            </form>
        </li>
    </ul>
</nav>
<div class="container">
    <h1>Hello World</h1>
</div>
<script src="/webjars/jquery/3.5.1/jquery.slim.min.js"></script>
<script src="/webjars/bootstrap/4.5.0/js/bootstrap.bundle.min.js"></script>
</body>
</html>

2.1.9.1 Security 配置

WebMvcConfig 添加 index 视图

 @Override
  public void addViewControllers(ViewControllerRegistry registry) {
      // 访问 login 就会访问这个视图
      registry.addViewController("/login").setViewName("login");
      registry.addViewController("/").setViewName("index");
      registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
  }
  • SecurityConfig
    • 添加 .logout(l -> l.logoutUrl("/perform_logout")) 对应 index.html 45行
    • 追加忽略路径:/error
// (debug = true) 请勿在生产系统中使用
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 指定路径需要认证
//                .authorizeRequests(r -> r.antMatchers("/api/**").authenticated())
                // 所有路径都需要认证
                .authorizeRequests(r -> r.anyRequest().authenticated())
                // 不添加 permitAll() 的话,就会一直“重定向”
                .formLogin(f -> f.loginPage("/login")
//                        .usernameParameter("username1")  // 指定username参数为username1,但需要前端配合传参也是 username1
                        .permitAll())
                .httpBasic(Customizer.withDefaults())
                .csrf(Customizer.withDefaults())
                .logout(l -> l.logoutUrl("/perform_logout")) // index.html 45行;  "/" 不能少,不然跳转404
        ;

    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/css/**","/error")
        .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

2.1.10 Remember-me 功能

  • 为解决 session过期后用户的直接访问问题
  • Spring Security 提供开箱即用的配置 Remember-me
  • 原理:使用 Cookie存储用户名,过期时间,以及一个Hash
  • Hash:MD5(用户名+过期时间+密码+key)

login.html 关于 Remember-me 代码片

 <div class="form-check">
     <input
             type="checkbox"
             class="form-check-input"
             id="remember-me"
             name="remember-me"
     />
     <label class="form-check-label" for="remember-me" th:text="#{login.page.form.remember-me}">
     Remember Me:</label>

application.yml 屏蔽用户密码权限

spring:
  application:
    name: spring-security-start
#  security:
#    user:
#      name: eddie
#      password: 123456
#      roles: USER,ADMIN

2.1.10.1 Security 配置

SecurityConfig 追加在内存中身份验证和加密密码

// 忽略前面代码..

 @Override
 protected void configure(HttpSecurity http) throws Exception {
     http
             // 指定路径需要认证
//                .authorizeRequests(r -> r.antMatchers("/api/**").authenticated())
             // 所有路径都需要认证
             .authorizeRequests(r -> r.anyRequest().authenticated())
             // 不添加 permitAll() 的话,就会一直“重定向”
             .formLogin(f -> f.loginPage("/login")
//                        .usernameParameter("username1")  // 指定username参数为username1,但需要前端配合传参也是 username1
                     .permitAll())
             .httpBasic(Customizer.withDefaults())
             .csrf(Customizer.withDefaults())
             .logout(l -> l.logoutUrl("/perform_logout")) // index.html 45行;  "/" 不能少,不然跳转404
             .rememberMe(r -> r.tokenValiditySeconds(30 * 24 * 3600).rememberMeCookieName("spring-security-start"))  // 保存一个月
     ;
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
            .withUser("eddie")
            .password(passwordEncoder().encode("123456"))
            .roles("USER","ADMIN")
    ;
}

@Bean
PasswordEncoder passwordEncoder() {
 return new BCryptPasswordEncoder();
}

// 忽略后面代码..

效果图

在这里插入图片描述

2.1.11 自定义登录与登出的处理

默认是由 Spring 处理的,现在想交给 Security 处理(SimpleUrlAuthenticationSuccessHandler)

  • 登录成功后的处理
    • AuthenticationSuccessHandler
  • 登录失败后的处理
    • AuthenticationFailureHandler
  • 退出登录成功后的处理
    • LogoutSeccessHandler

2.1.11.1 Security 配置登陆和登出

登陆成功后返回json格式

@Slf4j
// (debug = true) 请勿在生产系统中使用
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 指定路径需要认证
//                .authorizeRequests(r -> r.antMatchers("/api/**").authenticated())
                // 所有路径都需要认证
                .authorizeRequests(r -> r.anyRequest().authenticated())
                // 不添加 permitAll() 的话,就会一直“重定向”
                .formLogin(f -> f.loginPage("/login")
//                        .usernameParameter("username1")  // 指定username参数为username1,但需要前端配合传参也是 username1
                        // 登录成功,返回json
                        .defaultSuccessUrl("/")
                        .successHandler(jsonAuthenticationSuccessHandler())
                        // 登录失败,返回json
                        .failureHandler(jsonAuthenticationFailureHandler())
                        .permitAll())
                // 显示浏览器对话框,需要禁用 CSRF ,或添加路径到忽略列表
                .httpBasic(Customizer.withDefaults())
                .csrf(Customizer.withDefaults())
                // index.html  perform_logout 45行;  "/" 不能少,不然跳转404
                .logout(l -> l.logoutUrl("/perform_logout")
                        // 登出成功,返回json
//                        .logoutSuccessUrl("/login")
                        .logoutSuccessHandler(jsonLogoutSuccessHandler())
                        .deleteCookies("JSESSIONID")
                )
                .rememberMe(r -> r.tokenValiditySeconds(30 * 24 * 3600)
                        .rememberMeCookieName("spring-security-start")) // 保存一个月
        ;

    }
    
    private LogoutSuccessHandler jsonLogoutSuccessHandler() {
        return (request, response, authentication) -> {
            if (authentication != null && authentication.getDetails() != null) {
                request.getSession().invalidate();
            }
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().println();
            response.getWriter().flush();
            response.getWriter().close();
            log.debug("登出成功");
        };
    }
    
    private AuthenticationFailureHandler jsonAuthenticationFailureHandler() {
        return (request, response, exception) -> {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");
            final Map<String, String> errDate = new HashMap<>(16);
            errDate.put("title","认证失败");
            errDate.put("details", exception.getMessage());
            response.getWriter().println(
                    new ObjectMapper().writeValueAsString(errDate)
            );
            response.getWriter().flush();
            response.getWriter().close();
            log.debug("认证失败");
        };
    }

    private AuthenticationSuccessHandler jsonAuthenticationSuccessHandler() {
        return (request, response, authentication) -> {
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().println(
                    new ObjectMapper().writeValueAsString(authentication)
            );
            response.getWriter().flush();
            response.getWriter().close();
            log.debug("认证成功");
        };
    }
// 省略其余代码
}

2.1.11.2 测试流程

  1. 访问 http://localhost:8080/login 返回json
  2. 再次访问 http://localhost:8080/ 点击左上角 User ==> 退出登录

2.1.12 自定义 Filter

2.1.12.1 继承 UsernamePasswordAuthenticationFilter 重写 attemptAuthentication()

@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class RestAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	private final ObjectMapper objectMapper;

	/**
	 * 仿照父类源码 72行
	 */
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		UsernamePasswordAuthenticationToken authRequest = null;
		try {
			InputStream is = request.getInputStream();
			JsonNode jsonNode = objectMapper.readTree(is);

			String username = jsonNode.get("username").textValue();
			username = (username != null) ? username : "";
			username = username.trim();

			String password = jsonNode.get("password").textValue();
			password = (password != null) ? password : "";

			authRequest = new UsernamePasswordAuthenticationToken(username, password);
		} catch (IOException e) {
			e.printStackTrace();
			throw new BadCredentialsException("没有找到用户名或密码");
		}
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}
}

2.1.12.2 Security 配置

  • configure(HttpSecurity http) 里面的那块
  • restAuthenticationFilter()
@Slf4j
// (debug = true) 请勿在生产系统中使用
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final ObjectMapper objectMapper;
    
	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 所有路径都需要认证
                .authorizeRequests(r -> r
                        .antMatchers("/authorize/**").permitAll()
                        .antMatchers("/admin/**").hasRole("ADMIN")
                        .antMatchers("/api/**").hasRole("USER")
                        .anyRequest().authenticated())
                // restAuthenticationFilter() 替代 UsernamePasswordAuthenticationFilter.class 过滤器
                .addFilterAt(restAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                // 这几个路径不使用csrf token
                .csrf(c -> c.ignoringAntMatchers("/authorize/**","/admin/**","/api/**"))
        ;

    }

    private RestAuthenticationFilter restAuthenticationFilter() throws Exception {
        RestAuthenticationFilter filter = new RestAuthenticationFilter(objectMapper);
        filter.setAuthenticationSuccessHandler(jsonAuthenticationSuccessHandler());
        filter.setAuthenticationFailureHandler(jsonAuthenticationFailureHandler());
        // 调用父类 authenticationManager()
        filter.setAuthenticationManager(authenticationManager());
        filter.setFilterProcessesUrl("/authorize/login");
        return filter;
    }

    private LogoutSuccessHandler jsonLogoutSuccessHandler() {
        return (request, response, authentication) -> {
            if (authentication != null && authentication.getDetails() != null) {
                request.getSession().invalidate();
            }
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().println();
            response.getWriter().flush();
            response.getWriter().close();
            log.debug("登出成功");
        };
    }

    private AuthenticationFailureHandler jsonAuthenticationFailureHandler() {
        return (request, response, exception) -> {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");
            final Map<String, String> errDate = new HashMap<>(16);
            errDate.put("title","认证失败");
            errDate.put("details", exception.getMessage());
            response.getWriter().println(
                    new ObjectMapper().writeValueAsString(errDate)
            );
            response.getWriter().flush();
            response.getWriter().close();
            log.debug("认证失败");
        };
    }

    private AuthenticationSuccessHandler jsonAuthenticationSuccessHandler() {
        return (request, response, authentication) -> {
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().println(
                    new ObjectMapper().writeValueAsString(authentication)
            );
            response.getWriter().flush();
            response.getWriter().close();
            log.debug("认证成功");
        };
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("eddie")
                .password(passwordEncoder().encode("123456"))
                .roles("USER","ADMIN")
        ;
    }

    @Bean
    PasswordEncoder passwordEncoder() {
	    return new BCryptPasswordEncoder();
    }

    /**
     * 忽略指定${图片,音频,其他静态资源},不走 Security 过滤器检查
     * 会打印:Security filter chain: [] empty (bypassed by security='none')
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/css/**","/error")
                // 会自动查找到 css, js 哪些静态资源在哪里
        .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

2.1.12.3 PostMan测试截图

http://localhost:8080/authorize/login
在这里插入图片描述