文章目录
1. 简介
基于Spring框架,提供了一套Web应用完全性的完整解决方案。关于安全的两个主要区域:认证和授权
认证:就是系统认为用户是否能登录
授权:就是系统判断用户是否有权限去 做某些事情
特点:
- 和Spring无缝整合
- 全面的权限控制,功能多
- 专门为Web开发而设计(旧版本不能脱离Web环境使用,新版本对整个框架进行了分层抽取)
- 重量级框架(缺点)
同款的安全框架Shiro(Apache旗下的),特点:轻量级,通用性,灵活。缺点:再Web场景需要手动编写代码定制。
常见的技术栈组合:
SSM + Shiro
SpringBoot/SpringCloud + Spring Security
2. 入门介绍
2.1 使用
-
常见SpringBoot项目
使用的是
2.3.4.RELEASE
版本的SpringBoot -
引入相关依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
-
编写Controller
@Controller public class HelloController { @GetMapping("/hello") @ResponseBody public String hello() { return "Hello Spring Security"; } }
-
访问
http://localhost:8080/hello
默认会重定向到 /login默认用户名是user,每次启动控制台会输出一个默认的密码,登陆后就能正常访问了
2.2 本质
SpringSecurity本质是一个过滤器链,过滤器在项目启动的时候就进行加载,下面看几个:
FilterSecurityInterceptor
是一个方法级别的权限过滤器,基于位于过滤器链的最底部ExceptionTranslationFilter
异常过滤器,用来处理认证授权过程中抛出的异常UsernamePasswordAuthenticationFilter
对/login的Post请求做拦截,校验表单中的用户名密码
AuthenticationManager
,ProviderManger
,AuthenticationProvider
之间的关系⭐:
ProviderManger实现了AuthenticationManager接口(唯一实现),ProviderMangeer中有一个AuthenticationProvider的List集合,有多个,AuthenticationProvider可以有多个,建议我们去扩展AuthenticationProvider。
SpringSecurity支持多种不同的认证方式,如:用户名/密码认证,ReremberMe认证,手机验证码认证,每个不同的认证方式对应不同的AuthenticationProvider。底层会遍历所有的AuthenticationProvider去认证,只要有一个认证成功就登录成功。
ProviderManger也可也有多个,ProviderManger中可以有一个父亲ProviderManger,里面有通用的一系列认证规则,儿子ProviderManger可以有独特的认证规则。可以有多个儿子,但只能由一个父亲,这样设计便于后面的微服务。先使用本类ProviderManger中的所有AuthenticationProvider去认证,如果都认证不成功,就再去使用父亲ProviderManger中的所有AuthenticationProvider去认证。父亲相当于一种全局资源,作为所有提供者的后备资源。
如果后面我们想要扩展多种认证方式,最后修改全局的AuthenticationManager(ProviderManger),如果想要针对不同的资源进行不用的认证方式,即分的特别细则可以修改局部的(一般不会这么细)
全局的AuthenticationManager(ProviderManger)中有个AuthenticationProvider的实现叫DaoAuthenticationProvider,底层认证调用UserDetailService的实现类进行数据校验,最后根据返回的User在底层帮我们再进行密码的验证(因为会有一些加密)!
2.3 自动化配置
SpringBoot会自动化配置Spring Security:
SecurityFilterAutoConfiguration
-> SecurityProperties
-> spring.security配置项
里面注册的组件:
-
帮我们配了:
DelegatingFilterProxy
->DelegatingFilterProxyRegistrationBean
这个Filter里面
doFilter()
会调用initDelegate(wac)
里面会从容器中获取id为filterChainProxy的Bean(FilterChainProxy类型) -
调用
FilterChainProxy.doFilterInternal()
方法:在里面获取所有Filter,
List<Filter> filters = getFilters(..)
,即获取Security的过滤器链(十几个过滤器)进行加载到过滤链中
2.4 三个重要接口
如果要自定义验证用户名密码则需要继承UsernamePasswordAuthenticationFilter
类,重写attemptAuthentication()方法得到自己的用户名密码,成功重写successfulAuthentication()方法,失败重写unsuccessfulAuthentication()方法
-
UserDetailsService
这个实现类是查询数据库用户名和密码的过程,返回User对象(是安全框架中提供的对象)
-
PasswordEncoder
进行密码加密,用于User对象里面的密码加密
WebSecurityConfigurerAdapter
自定义 SpringSecurity
3. 设置用户名密码
3.1 内存实现
三种方法:
-
配置文件
spring.security.user.name=admin spring.security.user.password=123
-
通过配置类
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String password = encoder.encode("123"); // 加密 auth.inMemoryAuthentication().passwordEncoder(encoder) .withUser("admin").password(password).roles("管理员"); } }
-
自定义编写实现类⭐
- 编写UserDetailsService实现类,配置类中配置
- 再实现类在查数据库,返回User对象
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); auth.userDetailsService(userDetailsService).passwordEncoder(encoder); } }
@Service("userDetailsService") public class MyUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { // 查数据得到用户名密码以及操作权限,封装User返回 (User implements UserDetails) List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("role"); return new User("admin", new BCryptPasswordEncoder().encode("123"), auths); } }
BCryptPasswordEncoder
可以把这个对象放到容器,就不用每次new了
3.2 数据库实现
使用MybatisPlus(引入mysql和MyBatisPlus依赖)
-
创建表
t_user
,有字段id,username,password。然后配置数据源! -
创建实体类
MyUser
(防止和Security的User冲突)@TableName("t_user") public class MyUser { private Integer id; private String username; private String password; }
-
编写Mapper和Service (认证)
public interface MyUserMapper extends BaseMapper<MyUser> { } @Service public class MyUserDetailsService implements UserDetailsService { @Autowired private MyUserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 根据用户名去数据库查询 LambdaQueryWrapper<MyUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(MyUser::getUsername, username); MyUser user = userMapper.selectOne(wrapper); if (user == null) { // 认证失败,直接抛异常!! throw new UsernameNotFoundException("用户不存在"); } // 这里也可以数据库建立一个关系表,根据用户id查询出来对应的角色! List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("role"); return new User(user.getUsername(), new BCryptPasswordEncoder().encode(user.getPassword()), auths); } }
-
把
MyUserDetailsService
配置上,并且开启Mapper接口扫描@MapperSacn
@Configuration @MapperScan("com.sutong.mapper") public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUserDetailsService myUserDetailsService; // 自定义全局的AuthenticationManager(ProviderMange) @Override protected void configure(AuthenticationManagerBuilder builder) throws Exception { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); // 覆盖掉底层默认的的InMemoryUserDetailsService() builder.userDetailsService(myUserDetailsService).passwordEncoder(encoder); } }
3.3 认证信息获取
SpringSecurity会将用户认证信息也会保存到Session中,但为了方便,进行了线程绑定(默认使用ThreadLocal
实现的),将用户信息保存到SecurityContextHolder
中。
每当有请求发来,会先从Session取出用户信息,保存到SecurityContextHolder
中,方便后续处理使用,每当请求结束后会将SecurityContextHolder
数据保存到Session,同时将SecurityContextHolder
中数据清空!!
⭐实际上SecurityContextHolder中存储的是SecurityContext,SecurityContext中存有Authentication (认证后的用户信息)
后端使用:
@RequestMapping("/getUser")
@ResponseBody
public String getUser() {
Authentication au = SecurityContextHolder.getContext().getAuthentication();//默认子线程取不到
User user = (User) au.getPrincipal();
System.out.println("username:" + user.getUsername()); // 密码拿不到
System.out.println("权限和角色信息:" + au.getAuthorities());
return "success"; // 如果前后端分离直接返回json数据就行了
}
MODE_THREADLOCAL(只能当前线程访问) / MODE_INHERTABLETHREADLOCAL (当前和子线程) ,需改需要在加JVM参数spring.security.strategy=… ,不是简单的在配置文件中配置。
页面中怎么获取??引入Thymeleaf的扩展,可以更好的操作使用SecurityContextHolder
官网:thymeleaf-extras-springsecurit(前后端分离的话更简单了,就不用这个,直接获取json数据就行了)
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
使用(authentication认证,authorize授权):
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <!-- 命名空间-->
<div sec:authentication="name"></div> <div th:text="${#authentication.name}"></div> <!--用户名-->
<div th:if="${#authentication.authenticated}">已经登录</div>
<ul>
<li sec:authentication="principal.username"></li> <!-- 用户名,principal是当前用户对象-->
<li sec:authentication="principal.authorities"></li> <!-- 权限和角色-->
<li sec:authentication="principal.accountNonExpired"></li> <!-- 是否没过期-->
<li sec:authentication="principal.accountNonLocked"></li> <!-- 是否没锁定-->
<li sec:authentication="principal.credentialsNonExpired"></li> <!-- 凭证是否没过期-->
</ul>
<div sec:authorize="hasRole('ROOT')">是ROOT</div>
<div sec:authorize="isAuthenticated()">已经登录</div>
<div th:if="${#authorization.expression('hasRole(''ROLE_ADMIN'')')}">
仅当经过身份验证的用户具有角色ROLE_ADMIN时,才会显示此内容。
</div>
4. 自定义登录页面
4.1 配置
引入Thymeleaf 模板引擎
-
去配置类中配置⭐
@Configuration @MapperScan("com.sutong.mapper") public class SecurityConfig extends WebSecurityConfigurerAdapter { // 配置登录页(我们想要/hello保护,/index和/login.html不用认证) @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() // 可以定义哪些被保护哪些不被保护 .mvcMatchers("/login.html", "/index").permitAll() // 设置放行的资源,需要放到前面 .anyRequest().authenticated() // 除了上面的请求都需要登录 .and() .formLogin() // 自定义自己编写的登录页面 .loginPage("/login.html") // 覆盖默认的登录页面,(这个是login.html请求!不是直接访问) .loginProcessingUrl("/doLogin") // 默认是/login,一旦自定义页面后必须指定上!! .and() .csrf().disable(); // 关闭csrf跨站请求保护 } } /* .loginProcessingUrl("/doLogin") 指定登录处理的url!! 登录访问路径(表单提交后提交到那个Controller,不用我们写,SpringSecurity帮我们) (如果是前后端分离的话底层的getParameter就不行了,需要我们继承UsernamePasswordAuthenticationFilter, 去重写attemptAuthentication方法,在配置类中注入到容器,需要设置一些属性,例如AuthenticationManager.. 把json数据解析,提取username,password,再进行认证!!) 我们要替换这个Filter,而且执行顺序不能改变,可以这样: // before..放在过滤器链中那个filter之前 // after..放在过滤器链中那个filter之后 // at..用某个filter替换过滤器链那个filter! http.addFilterAt(MyUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); .usernameParameter("uname")可以改下面表单中input的name值。 .passwordParameter("passwd") */
-
创建登录页面
classpath:templates/login.html
<body> <!--①必须是post请求,②action必须和上面的loginProcessingUrl参数一致,这个请求SpringSecurity帮我们处理 ③表单中的两个input的name必须是下面的!username,password,--> <form th:action="@{/doLogin}" method="post"> 用户名:<input type="text" name="username"> <br> 密码:<input type="password" name="password"> <br> <input type="submit" value="登录"> </form> </body>
-
创建Controller
@Controller public class HelloController { @RequestMapping("/login.html") public String login() { return "login"; // 跳转到 templates/login.html } @RequestMapping("/index") public String index() { return "index"; } @GetMapping("/hello") @ResponseBody public String hello() { return "Hello Spring Security"; } }
web技巧:
public class WebConfig implements WebMvcConfigurer { // 这个就不用每个请求/login.html -> templates/login.html去写个控制器方法进行控制跳转了 @Override public void addViewControllers(ViewControllerRegistry registry) { //registry.addRedirectViewController(); 重定向跳转 registry.addViewController("/login.html").setViewName("login"); // 转发跳转,自动加前后缀 registry.addViewController("/index").setViewName("index"); } }
-
测试
访问
http://localhost:8080/index
不需要登录http://localhost:8080/hello
则需要到我们自定义页面登录,如果使用defaultSuccessUrl登陆后会继续访问我们的/hello请求
4.2 登录成功后处理
.successForwardUrl("..")
登录成功后forward转发的路径(这个不管之前有没有请求,都去这个)
.defaultSuccessUrl("..")
登录成功后redirect转发的路径(如果保存了登录前的路径就会优先跳转保存的),这个还有个第二个参数,如果设为true和successForwardUrl就效果一样了!这两个只能选一个
successHandler(..)
前后端分离的认证成功后的处理(认证成功后的处理,上面两个并不适用于前后端分离,我们想要返回json数据不需要跳转,通知是否认证成功,自定义类实现AuthenticationSuccessHandler,然后传入):
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
Map<String, Object> res = new HashMap<>();
res.put("msg", "登陆成功");
res.put("status", 200);
res.put("authentication",authentication); // 认证后的信息都在authentication里面
httpServletResponse.setContentType("application/json;charset=utf-8");
String jsonRes = new ObjectMapper().writeValueAsString(res); // 转化为json
httpServletResponse.getWriter().write(jsonRes);
}
}
4.3 登录失败后处理
如果认证失败后是forward跳转会把异常信息放入request域中
redirect则会放入session域中(默认是使用重定向跳转的),key都是SPRING_SECURITY_LAST_EXCEPTION
配置类中的方法 :
.failureForwardUrl("..")
forward跳转
.failureUrl("..")
redirect跳转,一般用这个
.failureHandler(..)
自定义类实现AuthenticationFailureHandler传入,和上面的successHandler(..)
一样,便于前后端分离
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
Map<String, Object> res = new HashMap<>();
res.put("msg", "登陆失败" + e.getMessage());
res.put("status", 500);
String jsonRes = new ObjectMapper().writeValueAsString(res); // 转化为json
httpServletResponse.getWriter().write(jsonRes);
}
}
5. 授权Authorization
主体:Principal
认证 Authentication
授权 Authorization
授权:访问控制,控制谁能访问哪些资源,可以基于权限或角色进行访问控制!
// 用户信息对象
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); // 获得当前登录用户具备的权限信息
//......
}
GrantedAuthority :如果是基于资源进行权限管理,则是权限字符串,如果基于资源进行权限管理,则是角色,如果两者都有则统称为权限。
从设计层面考虑,角色和权限是两个完全不同的东西,权限是一些具体操作,角色的某些权限的集合。
从代码方面没有太大不同,处理方式基本一样,唯一区别SpringSecurity会在角色前自动加ROLE_
前缀,权限则不会。
权限管理策略:
-
基于过滤器/URL的权限管理(FilterSercurityInterceptor)
基于Filter,拦截HTTP请求,根据HTTP请求地址进行权限校验
-
基于方法/Aop的权限管理(MethodSecurityInterceptor)
处理方法级别的权限问题,通过AOP将操作拦截下来,进行判断
5.1 基于URL的权限管理
基于资源进行权限管理:
-
hasAuthority()
如果当前的主体具有指定的权限,则返回 true,否则返回 false// 配置类加上,标识当前登录的用户具有admins的权限才能访问前面的路径 .mvcMatchers("/getInfo").hasAuthority("read_info")
在UserDetailsService的实现类中,返回的User中设置权限(当然这个权限要根据业务进行添加删除)
List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("read_info"); return new User(user.getUsername(), new BCryptPasswordEncoder().encode(user.getPassword()), auths); // 如果没有权限则会报错 (type=Forbidden, status=403),这个报错页面我们也可以自定义
-
hasAnyAuthority()
多个权限,如果当前的主体有任何提供的角色(给定的作为一个逗号分隔的字符串列表)的话,返回 true..mvcMatchers("/getInfo").hasAnyAuthority("read_info,write_info")
基于角色进行权限管理:
-
hasRole()
如果用户具备给定角色就允许访问,否则出现 403。 如果当前主体具有指定的角色,则返回 true。// 这里不用加前缀,hasRole方法里面会自动帮我们加上 .mvcMatchers("/protection").hasRole("employee") // 在添加角色使和上面不太一样,要加个ROLE_前缀!!! // 如果将来要在数据库查询角色,数据库中不会存储ROLE_前缀的,需要我们手动加上 List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_sale");
-
hasAnyRole()
表示用户具备任何一个角色都可以访问。.mvcMatchers("/protection").hasAnyRole("employee,boss")
antMatchers(),最早出现的,用法和后者一样
mvcMatchers(),使用SpringMVC用于匹配的相同规则,例如"/path"映射会匹配"/path",“/path/”,"/path.html"等,匹配不到会交给ant模式
regexMatchers(),支持正则表达式
5.2 基于方法的权限管理
MethodSecurityInterceptor 除了可以前置处理外还可以进行后置处理,即是一个环绕AOP。
开启权限注解功能 :
@Configuration @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, jsr250Enabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { } // securedEnabled 开启 @Secured // prePostEnabled 开启 @PostAuthorize @PostFilter @PreAuthorize @PreFilter 只有这四个支持权限表达式!! // jsr250Enabled 开启 JSR-250提供的注解 @DenyAll @PermitAll @RolesAllowed
-
@Secured
判断是否具有角色,另外需要注意的是这里匹配的字符串需要添加前缀“ROLE_“。在Controller的方法上使用注解,设置角色
@Secured({"ROLE_employee", "ROLE_boss"}) // 或的关系 @GetMapping("/protection") @ResponseBody public String protection() { return "test secured"; }
-
@PreAuthorize
进入控制器方法执行前的权限验证可以将登录用户的 roles/permissions 参数传到方法中,下面这四个都支持spEL。⭐
// PreAuthorize注解里面写四个方法都行,即权限表达式! // 当前登录用户角色是ADMIN,用户名是root才可以访问 /protection @PreAuthorize("hasRole('ADMIN') and authentication.name=='root'") @GetMapping("/protection1") public String protection1() { return "test preAuthorize1"; } // 当前登录的用户名和参数name的值对比认证,#name取的是方法的参数 @PreAuthorize("authentication.name==#name") @GetMapping("/protection2") public String protection2(String name) { return "test preAuthorize2"; }
-
PostAuthorize
在控制器方法执行后再进行权限验证,适合验证带有返回值的权限控制器方法使用:
// returnObject表示方法的返回值。可在注解里面使用 @PostAuthorize("hasRole('sale')") @GetMapping("/protection") public String protection() { System.out.println("haha"); //如果没有sale角色也能输出haha,校验在方法执行后(输出后跳转到没有访问权限页面) return "a protection"; } // 返回对象的id是1才能返回 @PostAuthorize("returnObject.id==1") @GetMapping("/testPostAuthorize") public MyUser testPostAuthorize() { return new MyUser(1, "su", "123"); }
-
@PreFilter
进入控制器之前对集合/数组类型的参数进行过滤(下面这两个用的不多)filterObject
是使用@PreFilter和@PostFilter时的一个内置表达式,表示集合中的当前对象。// value过滤规则,filterTarget要过滤的参数名(只有一个参数可以省略) @PreFilter(value = "filterObject.id%2==0", filterTarget = "list") @PostMapping("/testPreFilter") public void testPreFilter(@RequestBody List<MyUser> list) { list.forEach(t -> { System.out.println(t.getId() + "\t" + t.getUsername()); }); }
-
@PostFilter
权限验证之后对集合/数组类型的返回值进行过滤(留下用户名是 admin1 的数据)@PostFilter("filterObject.username=='admin1'") @RequestMapping("/getAll") public List<MyUser> getAllUser(){ ArrayList<MyUser> list = new ArrayList<>(); list.add(new MyUser(1L,"admin1","6666")); list.add(new MyUser(2L,"admin2","8888")); return list; // 返回前端就只有admin1的数据 }
5.3 源码分析
三个接口 AccessDecisionManager
, AccessDecisionVoter
, ConfigAttribute
处理授权的主要的过滤器是:FilterSecurityInterceptor
ConfigAttribute:用户每请求一个资源需要的角色会被封装成一个ConfigAttribute对象,在ConfigAttribute中只有一个getAttribute方法,获得角色的名称(String),一般都带有ROLE_前缀
AccessDecisionVoter:投票器,其实就是比较用户所具有的角色和请求某个资源所需的ConfigAttribute之间的关系
AccessDecisionManager:在该类中会挨个遍历AccessDecisionVoter,进而决定是否允许用户访问
动态的权限管理:
实际应用开发中,权限管理并不是硬编码到配置类中,而是需要存储到数据库,来实现动态的url权限管理,需要实现接口FilterInvocationSecurityMetadataSource,实现getAttribute方法
表结构:
用户
<-- 多对多,用户角色表–>角色
<-- 多对多,角色菜单表–>菜单
具体操作看不良人的视频!
5.4 自定义403页面
自定义页面 templates/unauth.html
:
<body>
<h1>没有访问权限</h1>
</body>
配置类:
// 配置403页面
@Override
protected void configure(HttpSecurity http) throws Exception {
http.mvcMatchers("/protection").hasAuthority("admins")
http.exceptionHandling().accessDeniedPage("/unauth.html"); // 这行
}
Controller:
@RequestMapping("/unauth.html")
public String unauth() {
return "unauth"; // 跳转到 templates/unauth.html
}
6. 用户注销
-
配置类中配置
http.logout().logoutUrl("/logout").logoutSuccessUrl("/index").permitAll(); // 这行都可以不写 // logoutUrl("/logout")这个不加也行,默认的/logout(get方式),(我们可以自定义Controller处理这个,例如/doLogout)
Ⅰ配置多个注销登录请求:
// 注销 http.logout().logoutRequestMatcher(new OrRequestMatcher( // 配置多个注销登录请求,还可以修改请求方式! new AntPathRequestMatcher("/login", "GET"), new AntPathRequestMatcher("/login02", "POST") )).logoutSuccessUrl("/index").permitAll();
Ⅱ 注销的前后端分离方案(不需要页面跳转,这需要返回注销成功的信息,自定义
LogoutSuccessHandler
):LogoutSuccessHandler
的实现类这里不举例,和上面的登录成功的handler类似。http.logout().logoutUrl("/logout").logoutSuccessHandler(new MyLogoutSuccessHandler()).permitAll();
-
index.html
里面创建一个注销超链接<body> <h1>欢迎</h1> <a th:href="@{/logout}">注销</a> </body>
点击超链接再次访问受保护资源就再次需要登录了,这个
/logout
请求SpringSecurity帮我们解决的,是LogoutFilter
处理的!!
7. 验证码
传统web步骤:
- 在表单添加验证码输入框
- 创建生成验证码的Controller
- 重写UsernamePasswordAuthenticationFilter,认证前要先进行比较验证码
-
依赖
<dependency> <groupId>com.github.penggle</groupId> <artifactId>kaptcha</artifactId> <version>2.3.2</version> </dependency>
-
生成验证码的配置
@Configuration public class KaptchaConfig { // 设置验证码的生成规则 @Bean public Producer kaptcha() { Properties prop = new Properties(); prop.setProperty("kaptcha.image.width", "150"); // 验证码的宽度 prop.setProperty("kaptcha.image.height", "30"); // 高度 prop.setProperty("kaptcha.textproducer.char.string", "0123456789"); // 验证码字符串 prop.setProperty("kaptcha.textproducer.char.length", "4"); // 验证码长度,还有很多配置.. Config config = new Config(prop); DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); defaultKaptcha.setConfig(config); return defaultKaptcha; } }
-
Controller(或者直接用KaptchaServlet)
@Controller public class VerifyCodeController { // 注入能生成验证码的类! @Autowired private Producer producer; // 发请求,响应一个验证码图片! @RequestMapping("/vc.jpg") public void verifyCode(HttpServletResponse resp, HttpSession session) throws IOException { // 1.生成验证码,保存到session(后面认证需要比较) String verifyCode = producer.createText(); session.setAttribute("kaptcha", verifyCode); // 2.生成验证码图片 BufferedImage bi = producer.createImage(verifyCode); // 3.响应图片(记得设置响应类型) resp.setContentType(MediaType.IMAGE_PNG_VALUE); // "image/png" ServletOutputStream os = resp.getOutputStream(); ImageIO.write(bi, "jpg", os); // ImageIO是Java提供的工具类,中间的参数是以什么格式 // 如果是前后端分离的话就不能使用响应流进行响应图片了,要使用Base64编码,这个方法返回值就需要是String了 //FastByteArrayOutputStream fos = new FastByteArrayOutputStream(); //ImageIO.write(bi, "jpg", fos); //return Base64Utils.encodeToString(fos.toByteArray()); } }
-
把生成验证码的url放行,即不需要认证
.mvcMatchers("/login.html", "index", "/vc.jpg").permitAll()
-
表单中添加验证码输入框,这时访问就能看到图片了
<form th:action="@{/doLogin}" method="post"> 用户名:<input type="text" name="username"> <br> 密码:<input type="password" name="password"> <br> 验证码:<input type="text" name="kaptcha"> <img th:src="@{/vc.jpg}"> <br> <input type="submit" value="登录"> </form>
-
重写Filter替换
UsernamePasswordAuthenticationFilter
参考UsernamePasswordAuthenticationFilter里面的写法!!
// 自定义验证码的filter public class KaptchaFilter extends UsernamePasswordAuthenticationFilter { public static final String FROM_KAPTCHA_KEY = "kaptcha"; private String kaptchaParameter = FROM_KAPTCHA_KEY; // 验证码的key,不在代码中写死,将来可以set @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // 1.判断是否是POST请求 if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: "+ request.getMethod()); } // 2.获取验证码(如果是前后端分离的话,从json中提取出来验证码,username,password,不能直接getParameter了) String verifyCode = request.getParameter(getKaptchaParameter()); String sessionVerifyCode = (String) request.getSession().getAttribute("kaptcha"); if (verifyCode != null && sessionVerifyCode != null && verifyCode.equalsIgnoreCase(sessionVerifyCode)) { return super.attemptAuthentication(request, response); } // 3.验证码不正确则抛异常,是自定义的验证码异常 throw new KaptchaNotMatchException("验证码不匹配"); } public String getKaptchaParameter() { return kaptchaParameter; } public void setKaptchaParameter(String kaptchaParameter) { this.kaptchaParameter = kaptchaParameter; } }
-
指定我们自定义的filter替换过滤器链中的哪个个Filter ⭐
public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private DataSource dataSource; // 自定义AuthenticationManager,从数据库查询User @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); auth.userDetailsService(myUserDetailsService).passwordEncoder(encoder); } // 上面这样自定义AuthenticationManager我们并不能直接注入,需要进行暴露本地的Bean,重写这个方法进行 + @Bean @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } // 配置我们自定义的Filter,注入容器 @Bean public KaptchaFilter kaptchaFilter() throws Exception { KaptchaFilter filter = new KaptchaFilter(); // 这个可以自定义表单里面的name,有默认值 (setPasswordParameter也行) filter.setKaptchaParameter("kaptcha"); filter.setFilterProcessesUrl("/doLogin"); filter.setAuthenticationManager(authenticationManagerBean()); // 一旦自定义就要指定认证管理器 // 认证成功和失败后的处理: filter.setAuthenticationSuccessHandler((request, response, authentication) -> { // 如果是前后端分离的话,响应对应的json数据就行 response.sendRedirect("/index"); }); filter.setAuthenticationFailureHandler((request, response, e) -> { // 自定义,则需要自己存异常信息了 response.sendRedirect("/login.html"); }); return filter; } // 配置需要认证url,认证页面,替换管理器 (还可以配置异常页面,注销,rememberMe...) @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .mvcMatchers("/login.html", "index", "/vc.jpg").permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") // 自定义的话,登录成功登录失败跳转的url就不能在这里写了 .and() .csrf().disable(); http.addFilterAt(kaptchaFilter(), UsernamePasswordAuthenticationFilter.class); http.exceptionHandling() .accessDeniedPage("/unauth.html"); // 授权异常处理,还有accessDeniedHandler //.authenticationEntryPoint(); //认证异常处理,如果是前后端分离的话,没有认证时,可以自定义返回json,而不是去login.html } }
8. 记住我
以前我们在Web阶段使用Cookie和Session相关技术实现过,下面我们使用数据库来实现:
数据库版本SpringSecurity帮我们封装了,简单了很多!
原理:
浏览器 <- (通过cookie存一个加密串) 认证成功 (存cookie的加密串,用户对应信息字符串)-> 数据库
再次访问,获取cookie信息(7,10…天有效),拿着cookie信息到数据库比对,如果查到对应信息则认证成功,登录
-
建表 (其实在
JdbcTokenRepositoryImpl
可以帮我们自动创建表)CREATE TABLE `persistent_logins`( `username` varchar(64) NOT NULL, `series` varchar(64) NOT NULL, `token` varchar(64) NOT NULL, `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`series`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
配置类:配置操作数据库的对象
JdbcTokenRepositoryImpl
和配置免登录的参数信息!!@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private DataSource dataSource; //我们没配置,使用的默认的数据源HikariDataSource @Bean public PersistentTokenRepository persistentTokenRepository() { JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl(); repository.setDataSource(dataSource); //repository.setCreateTableOnStartup(true); 这个会帮我们创建表,第一次的时候设置true就行 return repository; } @Override protected void configure(HttpSecurity http) throws Exception { // 免登录配置 http.rememberMe().tokenRepository(persistentTokenRepository()); // 配置操作数据库的对象 // tokenValiditySeconds(..) 有效时长,秒为单位 // userDetailsService(..) 设置UserDetailsService } }
-
在登录页面添加复选框,是否下次免登录
<form th:action="@{/doLogin}" method="post"> 用户名:<input type="text" name="username"> <br> 密码:<input type="password" name="password"> <br> 记住我:<input type="checkbox" name="remember-me" value="true"> <br> <!-- name默认必须是remember-me!当然可以在配置类中改参数名!value的可选值true,yes,1,on--> <input type="submit" value="登录"> </form>
-
登陆时勾选上自动登录,关闭浏览器再次访问就不用再次登录了(数据库也出现信息了!!)
前后端分离场景:
拓展
UsernamePasswordAuthentticationFilter
,重写attemptAuthentication()
方法(具体写法看上面的验证码的KaptchaFilter类似),解析json中的username,password,remember-me数据(把remember-me参数存到request域中下面会用到)扩展
PersistentTokenBasedRememberMeServices
,重写继承父类的rememberMeRequested()
方法,判断remember-me参数值是不是true|yes|1|on,是则返回true把我们扩展的
PersistentTokenBasedRememberMeServices
类,配置到容器中,并且在开启remeber的配置后面指定我们的自定义的类,即指定使用我们拓展的rememberMeServices。(注意:我们拓展的UsernamePasswordAuthentticationFilter,配置时也要设置一下rememberMeServices,因为登陆成功后要使用这个Services,往客户端写Cookie,而前面的设置的Services是在会话过期后进行比对的Cookie的)@Bean public MyPersistentTokenBasedRememberMeServices rememberMeServices() { return new MyPersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService(), persistentTokenRepository()); } @Override protected void configure(HttpSecurity http) throws Exception { http.rememberMe().rememberMeServices(rememberMeServices()); }
9. 密码加密
常见加密:
Hash算法
单向的,相同的明文加密的字符串相同,可以手动加"盐"
单向自适应函数(SpringSecurity推荐)
bcrypt(推荐)故意降低运行速度,为自己带"盐",即使相同的明文加密字符串都不相同
PBKDF2 故意降低运行速度,当需要FIPS认证时是个好的选择(美国用的多)
scrypt,argon2…
-
{noop}123 明文
-
{bcrypt}加密后的 bcrypt算法
-
{pbkdf2}加密后的 bcrypt算法
-
{MD5}加密后的 MD5算法
-
…
SpringSecurity5后默认的加密方式并不是上面的某一种,而是DelegatingPasswordEncoder
,这个不是一个具体的加密方式,这个默认的PasswordEncoder
会自动根据前缀,使用对应具体的xxxPasswordEncoder
,进行对数据库的密码解密!和用户输入的进行比对!
这样的设计好处:兼容性,便携性…
加密两种方式:
-
加前缀
{bcrypt}$2a$10$gU4BAdZX/a4pGtHcdJ4wIexSV39f5oK/CeYkREOBg3FMFrf71LPse
-
固定系统的加密方案
默认创建的是
DelegatingPasswordEncoder
,我们要修改这个,我们只要在容器中创建一个具体的xxxPasswordEncoder
,默认的就不会使用了,不需要加前缀了@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
10. 会话管理
当浏览器调用登录接口登录成功后,服务器和浏览器之间会建立一个会话(Session),在每次发送请求时都会携带一个SessionId,服务器会根据这个SessionId来判断用户身份。当浏览器关闭后,服务器的Session并不会自动销毁,需要在服务器端调用销毁方法,或者等Session过期自动销毁(Tomcat中默认30分钟)
在SpringSecurity
中,与HttpSession
相关的功能由SessionManagemenFilter
过滤器和SessionAuthenticationStrategy
接口来处理,过滤器将Session相关操作委托给接口去完成。
10.1 会话并发管理
是指当前系统中同一个用户可以同时创建多少个会话(可以理解为:同一个用户,在同一时间可以在多少个客户端登录),默认没有限制,可以在SpringSecurity中对此进行配置。
http.sessionManagement() // 开启会话关闭
.maximumSessions(1) // 允许会话最大并发一个客户端
.expiredUrl("/login.html"); // 超过最大限制,默认则会有会话被挤掉。传统web开发被挤下线后的处理
前后端分离下,被挤下线的的处理:
http.sessionManagement() .maximumSessions(1) // 前后端分离的处理(可以写个类实现SessionInformationExpiredStrategy接口) .expiredSessionStrategy(event -> { HttpServletResponse response = event.getResponse(); HashMap<String, Object> res = new HashMap<>(); res.put("status", 500); res.put("msg", "当前会话已经过期,请重新登录"); String s = new ObjectMapper().writeValueAsString(res); response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(s); response.flushBuffer(); });
另一种机制,禁止后来者登录,即一旦当前用户登陆成功,后来者无法再次使用相同的用户登录,直到当前用户主动注销登录。
http.sessionManagement() // 开启会话关闭
.maximumSessions(1) // 允许会话最大并发一个客户端
.maxSessionsPreventsLogin(true); // 登录之后禁止再次登录
10.2 会话共享
前面所讲的会话管理都是单机上的会话管理,如果当前是集群环境,前面所讲的会话管理方案会失效,此时可以使用spring-session结合redis实现session共享。
依赖:
<!-- 操作redirs的(记得配置连接redis)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 序列化session的-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
配置:
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 操作Session的对象
@Autowired
private final FindByIndexNameSessionRepository sessionRepository;
// 创建将Session同步到redis中的方案
@Bean
public SpringSessionBackedSessionRegistry sessionRegistry() {
return new SpringSessionBackedSessionRegistry(sessionRepository);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable()
.sessionManagement() // 开启会话关闭
.maximumSessions(1) // 允许会话最大并发一个客户端
.maxSessionsPreventsLogin(true); // 登录之后禁止再次登录
.sessionRegistry(sessionRegistry()) // 共享会话
}
}
11. CSRF漏洞保护
跨站请求伪造(Cross-site request forgery),是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。
简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个 自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。 这利用了 web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
从
Spring Security 4.0
开始,默认情况下会启用 CSRF 保护,以防止 CSRF 攻击应用 程序,Spring Security CSRF 会针对 PATCH,POST,PUT 和 DELETE 方法进行防护。
11.1 令牌同步模式
CSRF防御:在合法请求中额外携带一个攻击者无法获取的参数,就可以区分两种不同的请求,进而直接拒接恶意请求,称令牌同步模式。具体操作是,在每个HTTP请求中,除了默认自动携带的Cookie参数外,在提供一个安全的随机的字符串(CSRF令牌),这个CSRF令牌是由服务器端生成,生成后在HttpSession中保存一份。当前端请求到达后,将携带的CSRF令牌信息和服务器中保存的进行对比,不相等则拒接掉该请求。(注意**:每一个请求都会生成一个CSRF令牌,而且每次请求令牌都会重新生成,默认保存到Session作用域**)
CSRF令牌应该在服务器端生成。它们能够针对每个用户会话或个请求生成一次。每个请求一个令牌比每个会话一个要安全点,因为这样留给攻击者利用偷来的令牌的时间最少。
开启:
SpringSecurity 中开启csrf防御: http.csrf()
(默认也是开启的)
幂等性的请求(GET,HEAD,TRACE,OPTIONS)可以不携带CSRF令牌
11.2 传统web开发
开启后,如果页面经解析后发现有表单的话,会自动在提交的表单中加入如下代码(无需做任何配置)
如果不能自动加入的话,需要开启之后手动加上如下的代码,并随着请求提交。
<input type="hidden"
th:if="${_csrf}!=null"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"
/>
11.3 前后端分离
CSRF令牌不在由服务器端进行保存到Session作用域中,而是浏览器本身进行保存,即保存到Cookie中,即使恶意网站携带这个Cookie也不会请求成功的。因为服务器进行验证的话不仅看Cookie中是否有CSRF令牌,还得看请求头中是否有key=CSRF令牌,而恶意网站不知道怎么组装这个key=value
// 告诉SpringSecurity令牌存储的机制,即将CSRF令牌保存到Cookie中,key是CSRF-TOKEN,并允许前端获取
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
发请求 (两种)
-
请求参数携带令牌,
_csrf
参数{ "username": "root", "passward": "123", "_csrf": "2c184260-157d-4323-a087-ad962b8c251d" (这个令牌前端需要在Cookie中获取,key是CSRF-TOKEN) }
-
请求头中携带令牌,
X-CSRF-TOKEN
参数"X-CSRF-TOKEN" :"2c184260-157d-4323-a087-ad962b8c251d"
前后端分离我们更喜欢后者,即请求头携带,这样后端就不用重写
UsernamePasswordAuthenticationFilter
,去解析json数据获取去"_csrf"参数了。
12. CORS跨域
Spring 框架中对于跨域问题的处理方案有好几种
12.1 CORS简介
CORS(Cross-Origin Resource Sharing),由W3W指定的一种资源共享技术标准,即在请求头中添加一些字段,服务器告诉浏览器,哪些网站通过浏览器有权限访问哪些资源,同时规定,对于哪些可能修改服务器数据的HTTP请求方法(GET以外的),浏览器必须使用OPTIONS方法发起一个预检请求,如果服务器允许才发生实际的HTTP请求。
同源:协议+主机+端口 相同,同源策略CORS
简单请求:
GET请求为例子,发生一个简单的跨域请求,请求头如下:
Host: localhost:8080 # 要访问的服务器
Origin: http://localhost:8081 # 本服务器
Referer: http://localhost:8081/index.html
如果服务器支持该跨域请求,那么返回的响应头中应包含如下字段:
# Access-Control-Allow-Origin 字段来告诉浏览器访问资源的域
# 浏览器解析后发现该值包含当前页面所在的域,就知道这个跨域是被允许的
Access-Control-Allow-Origin: http://localhost:8081
非简单的请求:
OPTIONS /put HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Accept: */*
Access-Control-Request-Method: PUT
Origin: http://localhost:8081
Referer: http://localhost:8081/index.html
请求方法是OPTIONS,请求头Origin就告诉服务端当前页面所在域,请求头Access-Control-Request-Methods告诉服务器端即将发起的跨域请求所使用的方法。服务端对此进行判断,如果允许即将发起的跨域请求,则会给出如下响应:
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:8081
Access-Control-Request-Methods: PUT
Access-Control-Max-Age: 3600
Access-Control-Allow-Metbods字段表示允许的跨域方法,Access-Control-Max-Age 字段表示预检请求的有效期,单位为秒,在有效期内如果发起该跨域请求,则不用再次发起预检请求。预检请求结束后,接下来就会发起-一个真正的跨域请求,跨域请求和前面的简单请求跨域步骤类似。
12.2 Spring跨域解决方案
-
@CrossOrigin
可以添加到方法上,或者Controller上,来支持跨域
@Controller public class HelloController { // origins允许的域,*代表所有。 只写一个@CrossOrigin,默认代表允许所有域所有请求方法 @CrossOrigin(origins = "http://localhost:8081") @PostMapping("/post") @ResponseBody public String post() { return "hello post"; } }
- allowCredentials:浏览器是否应发生凭证信息,如Cookie
- allowedHeaders:请求被允许的请求头字段,*代表所有
- exposeHeaders:哪些响应头可以作为响应的一部分暴露出来
- origins:允许可访问的域列表,*代表所有
- maxAge:预检请求的有效期,默认1800(以秒为单位)
- methods:允许的请求方法,*代表所有
-
addCrosMapping
全局配置方法
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // 处理的请求地址 .allowedMethods("*") .allowedOrigins("*") .allowedHeaders("*") .allowCredentials(false) // 这两个可以不写 .exposedHeaders("") .maxAge(1800); } }
-
CorsFilter
Spring提供的处理跨域的Filter
@Configuration public class WebMvcConfig { @Bean public FilterRegistrationBean<CorsFilter> corsFilter() { FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>(); CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedMethods(Arrays.asList("*")); corsConfiguration.setAllowedOrigins(Arrays.asList("*")); corsConfiguration.setAllowedHeaders(Arrays.asList("*")); corsConfiguration.setMaxAge(1800L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfiguration); registrationBean.setFilter(new CorsFilter(source)); registrationBean.setOrder(-1); // -1表示在所有Filter之前执行,如果多个都是-1则按加载顺序执行 return registrationBean; } }
这三种的原理是一样的
12.3 Spring Security跨域解决方案
当项目引入Spring Security后,上面的@CrossOrigin和addCorsMappings都失效了,CorsFilter有没有失效要看设置的优先级,如果优先于Spring Security的过滤链,则依然有效,反之无效。(经过测试,即使上面设置-1也会失效)
解决方案:⭐⭐⭐
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 跨域问题
http.authorizeRequests()
.anyRequest().authenticated()
//.......
.and()
.cors().configurationSource(corsConfigurationSource())
.and()
.csrf().disable();
}
// 和上面的CorsFilter的配置一样,只是不需要我们创建了!
public UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
corsConfiguration.setAllowedMethods(Arrays.asList("*"));
corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
corsConfiguration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
}
13. 异常处理
Spring Security只是解决自己的认证和授权异常,其他的全局异常还是交给SpringMvc去处理的
Spring Security中异常只要分为两类
- AuthenticationException 认证异常
- AccessDeniedException 授权异常
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 异常处理
http.authorizeRequests()
.anyRequest().authenticated()
//.......
.and()
.exceptionHandling() // 前后端分离异常处理
.authenticationEntryPoint(((request, response, e) -> { // 认证异常,返回一些json也行
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("尚未认证,请认证操作");
}))
.accessDeniedHandler((request, response, e) -> { // 授权异常
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("无权访问");
})
.and()
.csrf().disable();
}
}