前言
spring security的超详细配置和使用攻略,包括从登录校验开始到不同页面、不同功能的权限管理,包括了整合thymeleaf框架、用户登录信息持久化处理、csrf防护等web安全。
一、SpringSecurity是什么?
SpringSecurity是SpringBoot支持的高度可自定义的安全框架,利用了SpringDI和SpringAOP的设计,提供了声明式安全访问控制功能,减少了大量为了安全配置所写的重复代码。
这里的安全主要是指用户权限管理,即不同的用户访问不同的页面和菜单,如果没有相关权限,则不能访问相关页面。
二、所需数据库设计
所有用户数据都是保存在数据库中,权限(角色)也应该保存在数据库中,用户与权限是多对多的关系,就需要一张中间表,所以现在需要三张表:用户表(user),权限表(role),权限用户中间表(role_user_link)。
web项目中,什么是权限的具体体现?就是各种url,不同的权限可以访问的url也不同,url与权限也是多对多的关系,所以又加了两张表:url表(url),url与权限中间表(role_url_link)。
基础需要一共是五张表:
但是一般用不到url表,暂时我是没有用到,所以还是基础用户角色三张表。
三、使用步骤
新建一个spring boot项目,导入基本的web、mysql、mybatis或mybatis–plus、thymeleaf、spring boot test、lombok等依赖,快速创建实体类、Dao和Service
1.添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2.创建SecurityConfig类
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {}
随着WebSecurityConfigurerAdapter的过时,创建SecurityConfig类也变得简单起来:
@EnableWebSecurity
@Configuration
public class SecurityConfig {}
就只是我们自己新建一个类,加上配置类注解就好,其中的方法下面会讲到。
3. 往配置类中加方法,实现登录验证
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf().disable();
httpSecurity.authorizeRequests()
.anyRequest().authenticated()//所有请求,都需要登录状态校验
.and()
.formLogin();
return httpSecurity.build();
}
}
往SecurityConfig类中添加上面的方法,并标注为Bean,即可实现简单的登录,即使自己没有写页面。
默认的url是 login,我们上面配置了拦截所有请求,所以输入任意url都会被拦截,然后跳到该登录页面。自带的页面,好像还不错:
SpringSecurity自带了登录页面,默认用户名为 username,密码在运行了项目后,会在控制台中打印出来,直接复制即可。
如果嫌麻烦,可以直接在application.yml中指定security默认的用户名和密码,但不建议这么做,因为这个用不到,毕竟用户数据要从数据库中取!
四、自定义登录功能—认证功能
这次要开始动真格的了,不仅要从数据库中取数据,还要走自己的登录页面并跳转到首页。
1.从数据库中获取用户数据
这个老生常谈,不知道在做项目的过程中做了多少次注册登录了。几乎都是从映射实体类、Dao层、Service层、Controller层等。
security要实现从数据库中获取用户数据也与以往步骤差不多,需要自己写Dao层,但只需要将查询到的用户实体对象交给SecurityService实现类即可,会自动走我们设定好的配置,不需要我们自己写controller了。
首先,新建一个SecurityService类,这个类需要实现UserDetailsService接口,然后需要实现其中的一个方法:
@Service
public class SecurityService implements UserDetailsService {
private final UserService userService;
public SecurityService(UserService userService) {
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getOne(new QueryWrapper<User>()
.eq("username", username));
if (user == null) {
throw new UsernameNotFoundException("user is not found");
}
//return这么一长串是因为我的user实体类名字是User,与org.springframework.security.core.userdetails.User重名了
return new org.springframework.security.core.userdetails.User(
username,
user.getPassword(),
AuthorityUtils.commaSeparatedStringToAuthorityList("")//该参数暂时用不到,无意义,参数随便填
);
}
}
这里我为了偷懒使用的是mybatis-plus,总体逻辑便是从数据库中查出对应的user对象,并返回userDetails.User,切记不是直接返回实体类对象!
再次到登陆页面之后,会发现即使输入了正确的数据表中的用户名和密码,控制台会报错:
There is no PasswordEncoder mapped for the id “null”:
ERROR 24820 --- [nio-8080-exec-8] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
这是由于Security出于安全,需要将密码加密后比对。因此我们还需要在前面SecurityConfig类中配置密码加密器:
/**
* 密码加密器,会把客户端传来的密码进行加密,然后跟数据库中的密码做对比,要求数据库中的密码也是加密过的
* 如果没有该加密器,spring security会报出异常:There is no PasswordEncoder mapped for the id "null"
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
再次登录,用户名密码正确,结果报出下面的异常Encoded password does not look like BCrypt:
WARN 14588 --- [nio-8080-exec-4] o.s.s.c.bcrypt.BCryptPasswordEncoder : Encoded password does not look like BCrypt
并且登录失败:
这是因为,程序内部使用的是加密后的密码与数据库中的明文密码进行比对,当然是错误的,这也是刚接触security常见的错误。解决方法有如下两种:
第一种(不推荐),在我们取出user对象后,在SecurityService中修改,userDetails.User传参的时候,将密码进行加密:
//return这么一长串是因为我的user实体类名字是User,与org.springframework.security.core.userdetails.User重名了
return new org.springframework.security.core.userdetails.User(
username,
new BCryptPasswordEncoder().encode(user.getPassword()),
AuthorityUtils.commaSeparatedStringToAuthorityList("")//无意义,参数随便填
);
这里要求整个项目中注册修改密码后都是存储密文,那我们做demo的数据还是来自我们自己添加,一个个做单元测试,输入明文后再复制密文到数据库也是一个获取密文可行的方法。
在做单元测试时发现,相同的明文每次加密密文都不同,但依旧能验证成功,这里推荐一个文章,感兴趣的朋友可以去看看:为什么密文不同还能比对成功
2. 自定义登录页面
要使用自己写的登录页面,只需要如下几步即可:
httpSecurity.formLogin()
.loginProcessingUrl("/toLogin")
.loginPage("/myLogin")
loginProcessingUrl代表的是页面中form表单的action,一旦接收到该url,就会跳转到登陆页面。
loginPage代表的是我们自定义的登陆页面。当然不要忘记了在controller中配置。
3. 登录成功后逻辑
登陆成功后一般就要跳转到别的页面,security提供了三个登录成功后的跳转方式,分别是:
httpSecurity.formLogin()
.defaultSuccessUrl()//默认
.successForwardUrl()//成功后跳转
.successHandler()//可选默认,也可自定义的
① defaultSuccessUrl(“/url”, alwaysUse)
defaultSuccessUrl有两个实现,一个只需要一个url参数,另一个还要一个boolean类型的参数。
defaultSuccessUrl默认登录成功后重定向,如果alwaysUse参数设置为false,则会跳转到登录前的页面,而不是url参数的页面。设置为true则会强制为url参数页面。不写为false。
successForwardUrl登录成功后内部请求转发,url地址不会变,所以会显示form的action,即loginProcessingUrl拦截的地址。
如果参数是:new ForwardAuthenticationSuccessHandler("/index")
登录成功后会是内部请求转发,与successForwardUrl()相同。但可以自定义实现了AuthenticationSuccessHandler接口的类,然后实现重定向,如:
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final String url;
public MyAuthenticationSuccessHandler(String url) {
this.url = url;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect(url);
}
}
使用自定义参数:
.successHandler(new MyAuthenticationSuccessHandler("/index"))
4. 登录失败后逻辑
httpSecurity.formLogin()
.failureUrl()
.failureForwardUrl()
.failureHandler()
主要讲一下failureUrl与failureForward的区别和使用注意事项:
① failureUrl
与defaultSuccessUrl类似,是通过重定向的方式进行跳转。
但是由于是失败后的跳转,是连最基本的登录认证都没有通过的,所以是没有任何权限的,如果重定向的页面有权限限制则会因为没有权限而回到登录页面,这点需要格外注意。
② failureForwardUrl
与successForwardUrl类似,是通过服务器内部转发来实现跳转的不再多说。
③ failureHandler()
与successHandler类似,不再赘述。
5. 自定义表单的键名
登录功能数据来源于前端页面发送来的表单数据,SpringSecurity默认的键名(form表单中name属性)分别是:username和password。
如果想要使用自定义键名,只需要在前端表单写好后,再在SecurityConfig中添加如下代码即可:
.usernameParameter("username")
.passwordParameter("password")
这里的username和password就可以改为你自定义的键名。
6. rememberMe功能
rememberMe顾名思义就是记住我功能,要开启此功能只需要在securityConfig配置类中添加代码即可:
httpSecurity.rememberMe()
.tokenValiditySeconds(60*60*24*14)//cookie过期时间,单位秒,默认2周,过期后数据库中的记录并不会删除。
.rememberMeCookieName("rememberMe")
.userDetailsService(securityUserDetailsService)//登录逻辑交给哪个对象
.tokenRepository(persistentTokenRepository)//持久层对象
.rememberMeParameter("rememberMe")
下面逐个了解分别配置了什么东西。
-
rememberMe()代表开启了rememberMe功能
rememberMe的原理是将一个名为remember-me的cookie存储在浏览器中,默认时长两周,后续访问都会先去cookie中去找。 -
tokenValiditySeconds()设置cookie保存时间
cookie过期时间,单位秒,默认2周,过期后数据库中的记录并不会删除。 -
.userDetailsService(securityUserDetailsService)//登录逻辑交给哪个对象
.tokenRepository(persistentTokenRepository)//持久层对象这两个是重点,cookie保存在浏览器并不安全,有时还会被删除,因此应该利用持久层,将登录信息和用户数据保存在数据库中。
security就提供了这样的功能,只需要在SecurityConfig类中添加持久层的相关配置,即可自动在数据库中建立表和插入数据。
@Bean
public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) throws SQLException {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
//根据表名查找表
ResultSet resultSet = Objects.requireNonNull(jdbcTokenRepository.getDataSource()).getConnection().getMetaData().getTables(null, null, "persistent_logins", new String[]{"TABLE"});
//如果已经存在则跳过,如不存在则创建
if (resultSet == null || !resultSet.next()) {
jdbcTokenRepository.setCreateTableOnStartup(true);
}
return jdbcTokenRepository;
}
然后利用spring的自动注入实例化persistentTokenRepository,再在需要的地方使用。
构造器注入:
private final PersistentTokenRepository persistentTokenRepository;
public SecurityConfig(PersistentTokenRepository persistentTokenRepository) {
this.persistentTokenRepository = persistentTokenRepository;
}
需要注意的是,由于关于persistentTokenRepository的Bean是直接写在该配置类中的,如果要在该配置类中自动注入自己的类,会有循环依赖的问题,所以需要在构造器的参数上加上@Lazy注解:
private final PersistentTokenRepository persistentTokenRepository;
public SecurityConfig(@Lazy PersistentTokenRepository persistentTokenRepository) {
this.persistentTokenRepository = persistentTokenRepository;
}
配置好以后,启动项目就会自动检查数据库中是否有名为persistent_logins的表,如果没有则会自动创建,如果有,则会跳过创建。
表中的数据即使用户信息过期了,也不会删除。
如果未过期,则更新最近登陆时间。
如果过期了再登录会新插入一条记录。
7. 退出登录功能
httpSecurity.logout()//会清除rememberMe该用户的登录记录
.logoutUrl("/exit");//要与前端退出登录的url一致
如果开启了rememberMe功能,并且配置了持久层连接数据库,那么退出登录时会将该用户的记录全部删除,无论是过期还是未过期的记录。
logout的其余配置,包括退出成功后、退出失败后等等与登录的配置大同小异,这里不再赘述。
五、授权功能
1. 取得用户角色和权限
还记得在前面4.1中的SecurityService中根据用户名字获取user对象并返回UserDetails的方法吗?
//return这么一长串是因为我的user实体类名字是User,与org.springframework.security.core.userdetails.User重名了
return new org.springframework.security.core.userdetails.User(
username,
user.getPassword(),
AuthorityUtils.commaSeparatedStringToAuthorityList("")//该参数暂时用不到,无意义,参数随便填
);
其中返回的对象中有个参数是 AuthorityUtils.commaSeparatedStringToAuthorityList("")
,当时用不到所以是无意义的,现在权限校验需要用到了。
首先,我们从数据库中获取到user对象后,可以根据userId查找role_user_link表找到roleId,再根据roleId找到role。注意到该参数是一个集合,所以查找结果应该是个List<String>,即使一个用户可能只有一个角色。
根据数据库设计不同,可能是权限与用户绑定,那么一个用户就可以有多个权限,使用List<Strign>是不是就合理多了?
当然也有的数据库设计,既有角色,又有权限,即:用户表、用户角色中间表、角色表、角色权限中间表、权限表。这样可以即查出角色又查出权限,然后一起塞到AuthorityList中。
下面是完整的SecurityService类:
@Service
public class SecurityService implements UserDetailsService {
private final UserService userService;
private final RoleUserLinkService roleUserLinkService;
private final RoleService roleService;
public SecurityService(UserService userService, RoleUserLinkService roleUserLinkService, RoleService roleService) {
this.userService = userService;
this.roleUserLinkService = roleUserLinkService;
this.roleService = roleService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getOne(new QueryWrapper<User>()
.eq("username", username));
if (user == null) {
throw new UsernameNotFoundException("user is not found");
}
List<String> roles = new ArrayList<>();
roleUserLinkService.listObjs(new QueryWrapper<RoleUserLink>()
.select("role_id")
.eq("user_id", user.getId()))
.forEach(roleId -> roles.addAll(roleService.listObjs(new QueryWrapper<Role>()
.select("role")
.eq("id", roleId)).stream().map(Object::toString).collect(Collectors.toList())));
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
//这里其实是权限,而不是角色
roles.forEach(role -> authorityList.add(new SimpleGrantedAuthority(role)));
//角色应该这样写,我这里没有权限,只有角色。所以就都添加了,去掉ROLE_就是一样的
roles.forEach(role -> authorityList.add(new SimpleGrantedAuthority("ROLE_" + role)));
//return这么一长串是因为我的user实体类名字是User,与org.springframework.security.core.userdetails.User重名了
return new org.springframework.security.core.userdetails.User(
username,
//在线BCrypt加密 https://www.bejson.com/encrypt/bcrpyt_encode/
user.getPassword(),
authorityList
);
}
}
我的数据库设计是三张表,即:用户表、角色表、中间表。所以角色充当了权限和角色两种。
从代码可见,anthorityList.add()中针对角色有特定的前缀:“ROLE_”,如果要插入role,想要按照role进行验证,这个前缀是必须的。
2. 配置校验规则
现在我们已经取到了登录用户的角色和权限信息,下面就是去配置类中指定哪些url需要哪些权限或角色才能访问:
httpSecurity.authorizeRequests()
.antMatchers("/myLogin").permitAll()//myLogin所有人都可以访问
.antMatchers("/index").permitAll()
.antMatchers("/studentIndex").hasAnyRole("student", "admin")//按照角色校验,但是不加 ROLE_ 的前缀,双引号
.antMatchers("/teacherIndex").hasAnyAuthority("teacher", "admin")//按照权限校验,双引号
.antMatchers("/adminIndex").hasAnyAuthority("admin")
.antMatchers("/teacherIndex").access("@myPowerServiceImpl.isPowerEnabled(request, authentication)")//自定义拦截,参数只能写request,不能写httpServletRequest,不知道为什么,也不知request哪里来的
.anyRequest().authenticated()//其他请求需要登录状态校验
- authorizeRequests()开启对request的验证
- antMatchers()指定要验证的url,后面跟上对该url的验证规则
- permitAll()表示该url所有人都可以访问,一般用于公共页面,登录、首页、错误等
- hasAnyRole、hasRole:该url只有这些角色才能访问,不同的是anyRole后面可以跟多个角色,role后面只能跟一个角色。这里角色不必加我们获取到角色时添加的前缀ROLE_。
- hasAnyAuthority、hasAuthority:该url只有拥有这些权限才能访问,any带与不带的区别同上。
- access()下面会讲到
- anyRequest():其他请求
- authenticated():处于登陆状态即可访问
myLogin、index所有人都可以访问,无论登不登陆;
studentIndex 角色为 student 和 admin 可以访问;
teacherIndex 角色为 teacher 和 admin 可以访问;
adminIndex 角色为 admin 可以访问;
其他请求,只要成功登录了就可以访问。
3. 自定义拦截规则
有的项目中会要求权限模糊校验,与权限对比有一部分通过校验即可访问,比如:有权限名为 class:manger:student,项目要求只要有class就可以通过校验。
这个例子尽管不恰当,但这种要求可以通过自定义access()中的参数实现:
我们先定义一个接口类:
public interface MyPowerService {
Boolean isPowerEnabled(HttpServletRequest request, Authentication authentication);
}
然后实现它:
@Service
public class MyPowerServiceImpl implements MyPowerService {
@Override
public Boolean isPowerEnabled(HttpServletRequest request, Authentication authentication) {
Object user = authentication.getPrincipal();
if (user instanceof UserDetails) {
UserDetails userDetails = (UserDetails) user;
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
//角色需要写ROLE_,权限不用,我这里没有权限,只有角色。所以就都添加了,去掉ROLE_就是一样的
// boolean enabled1 = authorities.contains(new SimpleGrantedAuthority("ROLE_student"));
boolean enabled2 = authorities.contains(new SimpleGrantedAuthority("teacher"));
boolean enabled3 = authorities.contains(new SimpleGrantedAuthority("ROLE_admin"));
return enabled2 || enabled3;
}
return false;
}
}
.antMatchers("/teacherIndex").access("@myPowerServiceImpl.isPowerEnabled(request, authentication)")//自定义拦截,参数只能写request,不能写httpServletRequest,不知道为什么,也不知request哪里来的
注意isPowerEnabled()中的第一个参数,虽然实现类中定义时是httpservlet,但是只能写request
4. 通过注解方式实现授权
如果觉得上面的方式过于麻烦,且写起来又臭又长,那么下面就是喜闻乐见的注解方式了。
使用注解的方式添加授权的步骤如下:
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, proxyTargetClass = true)
- 在controller层的请求处理方法上面按照格式添加注解即可,例如:
@Secured({"ROLE_student","ROLE_admin"})//作用相当于SecurityConfig中的securityFilterChain中的hasAnyRole,但是需要加ROLE_
@RequestMapping("/studentIndex")
public String studentIndex() {
return "student/studentIndex";
}
@Secured({"ROLE_teacher","ROLE_admin"})
@PreAuthorize("hasAnyAuthority('teacher', 'admin')")//作用相当于SecurityConfig中的securityFilterChain中的hasAnyAuthority,但里面是单引号
@RequestMapping("/teacherIndex")
public String teacherIndex() {
return "teacher/teacherIndex";
}
@Secured({"ROLE_admin"})
@PreAuthorize("hasAnyAuthority('admin')")//在进行操作前就拦截
// @PostAuthorize("hasAnyAuthority('admin')")//在进行操作后才进行校验
@RequestMapping("/adminIndex")
public String adminIndex() {
return "admin/adminIndex";
}
注解格式介绍:
- @Secured({“ROLE_student”,“ROLE_admin”})
跟SecurityConfig类中的hasAnyRole很像,只是hasAnyRole()参数不必加”ROLE_”前缀。 - @PreAuthorize(“hasAnyAuthority(‘teacher’, ‘admin’)”)
作用相当于SecurityConfig中的securityFilterChain中的hasAnyAuthority,但里面是单引号 - @PostAuthorize(“hasAnyAuthority(‘admin’)”)
作用相似于SecurityConfig中的securityFilterChain中的hasAnyAuthority,但里面是单引号 - @PreAuthorize和@PostAuthorize的区别
@PreAuthorize是在执行处理请求的方法之前进行授权校验,如果不通过则不会执行方法。
@PostAuthorize则是在执行过方法之后才进行权限校验,如果不通过则不会有反馈,一般用于特殊需求。
六、CSRF跨站伪造请求攻击防护
1. 什么是跨站伪造请求攻击?
用户在客户端浏览器访问A站,保存登陆信息后,又去浏览网站B,网站B偷偷获取cookie中的用户信息,然后拿着该信息去访问网站A,这个过程用户并没有在网站A做任何操作,一系列操作都是网站B拿着用户信息去伪造的,这就是跨站伪造请求攻击。
2. csrf原理
-
既然网站B只能获取到cookie的值,用户操作的时候一定是停留在网站网页上的,所以csrf的原理就是在请求中添加一个taken,只有taken和cookie同时符合的时候才能访问页面。
-
网站B只能获取cookie中的值,不能获取页面的值,该taken也不能让用户看到,所以前端控件属性应该是hidden的。
3. csrf的使用
<form method="post" id="userForm" action="/toLogin"><!-- action都会被拦截 -->
<input type="hidden" name="_csrf" th:if="_csrf" th:value="${_csrf.token}"><!-- 每个需要做验证的页面都要写 -->
<label>
账号:<input type="text" name="username"/>
</label>
<label>
密码:<input type="password" name="password"/>
</label>
<label>
<input type="checkbox" name="rememberMe" value="true"/>记住我?
</label>
<button type="submit" name="login">登录</button>
</form>
其中name固定为 _csrf,搭配thymeleaf使用。
后端:仅需这一句即可:
httpSecurity.csrf();
如果开启了该功能,几乎每个页面都必须有这样的一个存储taken的地方,否则会报出权限不足的异常。
总结
本文仅仅简单介绍了SpringSecurity的使用。
第一次写,如有错误欢迎指出,欢迎给出宝贵的建议和意见。
原文地址:https://blog.csdn.net/qq_42713539/article/details/129721027
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.7code.cn/show_50437.html
如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除!