目录
前言
Spring Security是非常流行的安全(权限)框架,Web应用框架。
主要有两大作用:一个是认证
,一个是授权
。
本质上,它就是Filter过滤器。而且是过滤器链。
SpringSecurity与Shiro的区别
Spring Security的特点:
-
Spring家族的,能很好的整合Spring。
-
专门为Web应用开发设计的。
-
提供专业全面的权限。
-
重量级的。依赖于很多其他组件。在SSM中整合比Shiro麻烦。但在springboot中提供了自动配置方案。
Shiro的特点:
-
它是Apache下的轻量级的权限框架。
-
轻量级的,依赖少,本身的大小也相对小。
-
不局限于Web环境,JavaSE下也可以运行。
-
缺点是针对Web环境下特定需求需要手动编写代码定制。功能没有Spring Security强大。
一般来说,常见的安全管理技术栈:
SSM+shiro
Spring Boot + Spring Security
一、引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当然了,完整的springboot工程还会引入其他所需要的相关依赖,具体根据项目而定:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
二、提供正常的业务接口
例如我这里提供的测试业务接口,具体的业务逻辑就不展示了:
三、自定义用户认证
实现通过查找数据库来获取用户名密码,完成登录功能。具体的密码校验由spring security内部完成。
3.1 编写配置类
设置使用哪个UserDetailsService 实现类
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(password());
}
@Bean
public PasswordEncoder password(){
return new BCryptPasswordEncoder();
}
}
3.2 编写UserDetailsService实现类
这个UserDetailsService接口是springsecurity内部提供的,我们只需要编写对应的实现类即可完成用户认证授权
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hssy.authoritydemo.entity.User;
import com.hssy.authoritydemo.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
List<GrantedAuthority> authorities =
AuthorityUtils.commaSeparatedStringToAuthorityList("manager");
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUsername,username);
User user = userMapper.selectOne(queryWrapper);
if (user == null){
throw new UsernameNotFoundException("用户不存在");
}
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
new BCryptPasswordEncoder().encode(user.getPassword()),
authorities
);
}
}
3.3 启动项目,完成认证功能的验证
在验证之前,我们先为数据库中创建一个测试用户。
然后启动项目,我们访问任意的接口,即便是没有编写的接口,它默认都跳转到spring security自带的登录页面了。
此时,我们即可使用数据库准备好的测试用户【username:security】 【password:123456】进行验证。
假如使用错误的用户名密码,是无法登录的。
登录成功,完成跳转,由于我们没有编写对应的接口,所以如下是404白标签页面。
如果我们之前访问login接口,则登录成功会跳转到根路径,也就是localhost:8080
此时访问我们之前提供的接口,就都能正常访问了,如
3.4 小说明
通过以上案例,我们知道:
1. 系统默认会为我们提供一个登录页面和登录接口,我们不编写相应页面和接口代码也能实现。
2. 密码校验是由SpringSecurity内部完成。不需要我们来处理。我们只需要将数据库查出来的用户名和密码交给spring security提供的User类即可。
3. 如果想自定义登录页面或者登录处理接口,那么还需要增加一项配置。下面一起来看看吧。
3.5 自定义用户登录页面及访问权限基本设置
3.5.1 代码
主要是通过配置类中,重写configure(HttpSecurity http)的这个方法
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()//表示进行表单登录
.loginPage("/login.html")//自定义的登录页面
.loginProcessingUrl("/login")//传一个登录处理的接口,不管你传的接口地址是什么,都由Security内部完成。当然也可以自己写这个接口,这样就不会用系统来完成登录处理校验用户名密码了。还有就是如果自定义了登录页面,那么登录处理的接口loginProcessingUrl项一定要写,不管是写系统自带的,还是你自己写的处理接口都行,否则报错。
.usernameParameter("username") //定义登录时的用户名的key,即表单中name的值,默认为username
.passwordParameter("password") //定义登录时的密码key,即表单中name的值,默认是password
//设置的这两个用户名、密码的key,如果不自己写登录页面的话,可以不用写,因为系统默认提供的页面就是这个默认值。写了的话,一定要与表单页面中定义的name值一致才行。
.defaultSuccessUrl("/pages/main")//登录成功跳转到的页面或者路径。当然,如果你不是从登录页面登录的,那么拦截之后会进入到你的请求路径(或页面)中
.failureUrl("/login.html")//登录失败跳转到的页面
.permitAll() //指和登录表单相关的接口 都通过,不拦截
.and()
.authorizeRequests()//开启授权请求
.antMatchers("/","/pages/main","/login").permitAll()//设置哪些路径放行,不需要认证 不需要登录可以访问的
.anyRequest().authenticated()//除开上面的,其他所有请求全部都需要权限验证。因为还没有用户授权,所以目前所有的接口登录后都能访问。
.and()
.csrf().disable();//关闭csrf防护
}
3.5.2 验证
此时重启项目,它就会自动跳转到我们配置的login.html页面,由于没有编写登录页的代码,它就会报错404。
我们编写一份前端代码吧
此时再次重启验证
当然了,我这里一开始地址栏输入的是访问/data-city/findAll这个接口,否则直接访问登录页的话,它就会跳转到我们配置的登录成功后的路径去。
四、授权
我们再开始之前再多写几个测试接口
前面我们在重写configure(HttpSecurity http)方法中,有开启基本的授权配置。
但是之前因为还没有用户授权,也没有配置哪些路径需要什么样的权限才能访问,所以所有的接口在登录后都能访问。
4.1 增加的授权代码
所以,我们只需要再增加哪些路径需要什么权限才能访问即可完成授权。其他不用变
.antMatchers("/security/test1").hasAuthority("admin")//表示当前登录用户,只有具有权限名称为admin时,才能访问此地址
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()//表示进行表单登录
.loginPage("/login.html")//自定义的登录页面
.loginProcessingUrl("/login")//传一个登录处理的接口,不管你传的接口地址是什么,都由Security内部完成。当然也可以自己写这个接口,这样就不会用系统来完成登录处理校验用户名密码了。还有就是如果自定义了登录页面,那么登录处理的接口loginProcessingUrl项一定要写,不管是写系统自带的,还是你自己写的处理接口都行,否则报错。
.usernameParameter("username") //定义登录时的用户名的key,即表单中name的值,默认为username
.passwordParameter("password") //定义登录时的密码key,即表单中name的值,默认是password
//设置的这两个用户名、密码的key,如果不自己写登录页面的话,可以不用写,因为系统默认提供的页面就是这个默认值。写了的话,一定要与表单页面中定义的name值一致才行。
.defaultSuccessUrl("/pages/main")//登录成功跳转到的页面或者路径。当然,如果你不是从登录页面登录的,那么拦截之后会进入到你的请求路径(或页面)中
.failureUrl("/login.html")//登录失败跳转到的页面
.permitAll() //指和登录表单相关的接口 都通过,不拦截
.and()
.authorizeRequests()//开启授权请求
.antMatchers("/","/pages/main","/login").permitAll()//设置哪些路径放行,不需要认证 不需要登录可以访问的
.antMatchers("/security/test1").hasAuthority("admin")//表示当前登录用户,只有具有权限名称为admin时,才能访问此地址
.anyRequest().authenticated()//除开上面的,其他所有请求全部都需要认证。
.and()
.csrf().disable();//关闭csrf防护
}
4.2 验证
重启项目,然后登录跳转,发现403,说明成功了,我们的页面没有权限,403表示无权限禁止访问。
如果给我们的用户增加权限呢,就是再我们的UserDetailsService实现类中,重写方法增加这个权限即可。
当然呢,正常情况下,不同的用户会有不同的权限,我们可以通过在数据库添加权限,然后查询数据库的权限,传递过来即可。就不用写死。
我们再重启测试一下,发现就能访问了。
4.3 常见的授权方法
除了上述案例中的使用到了第一个授权方法:
1. hasAuthority(String authority)
意思是如果当主体具有指定的权限,则返回true,否则返回false。
2. hasAnyAuthority(String... authorities)
如果当前主体具有任意一个权限,则返回true,否则返回false。
3. hasRole(String role)
如果当前主体具有指定的角色,则返回true,否则返回false。
需要注意的是如果是hasRole,那么在userDetailsService实现类中的角色名前面一定要添加ROLE_
4. hasAnyRole(String... roles)
如果当前主体具备任何一个角色,则返回true,否则返回false。
4.4 自定义403页面
通过以上的授权方法,我们可以完成授权功能。当我们的用户没有相应的权限时,则会出现403白标签页面。
为了更加友好的展示,我们会选择自定义403页面。
具体的做法也很简单。
4.4.1 修改访问配置类
增加代码:
http.exceptionHandling().accessDeniedPage("/unauth");
4.4.2 添加对应控制器方法
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SystemController {
@GetMapping("/unauth")
public String unauth(){
return "当前用户无权限访问";
}
}
4.4.3 重启测试
待访问/security/test2接口,我们配置一下,不给测试用户相应的权限,就无权限访问。
.antMatchers("/security/test2").hasAnyAuthority("fang1","fang2")
4.4.4 其他写法
我们的控制器方法,也可以是正常的返回一个Result对象,这样如果是前后端分离的,根据Result对象由前端去生成相应的页面也可以。
或者,后端也可以提供一个页面,返回一个转发试图。
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
//@RestController
@Controller
public class SystemController {
@GetMapping("/unauth")
public String unauth(){
// return "当前用户无权限访问";
return "forward:403.html";
}
}
五、授权(注解方式)
除了上述的在配置文件中通过配置hasAuthority、hasAnyAuthority、hasRole、hasAnyRole这些方法配置外,我们也可以在对应的控制器方法上,添加对应的注解来进行授权访问。
5.1 主启动类上添加注解
@EnableGlobalMethodSecurity(securedEnabled=true)
5.2 控制器方法添加相关授权注解
@Secured
判断是否具有某个角色。
另外需要注意的是这里匹配的字符串需要添加前缀“ROLE_“。
以上说明拥有teacher或者student角色的用户才能访问该方法。
但是如果要求同时满足拥有这两个角色的用户才能访问,@Secured注解就无能为力了。
@PreAuthorize(重点)
判断是否具有某个角色或权限,也判断是否同时具有某些角色或权限
它比@Secured的能力更大,@Secured只能判断是否具有某个角色
//拥有normal或者admin角色的用户都可以方法helloUser()方法。
@GetMapping("/helloUser")
@PreAuthorize("hasAnyRole('normal','admin')")
public String helloUser() {
return "hello,user";
}
//同时拥有normal和admin角色的用户才能访问
@GetMapping("/helloUser")
@PreAuthorize("hasRole('normal') AND hasRole('admin')")
public String helloUser() {
return "hello,user";
}
六、关于密码加密补充说明
前面我们代码中使用到了两处
一处是配置类中注入了PaswordEncoder的bean对象
第二处是UserDetailsService实现类中,返回User对象时,第二个形参中设置的密码加密。
其实,通常而言,在配置类中注入PaswordEncoder的bean对象是必须的,因为Spring Security 要求容器中必须有 PasswordEncoder 实例,才能加密。所以当我们手动加入自定义登录逻辑时,要求必须给容器注入PaswordEncoder的bean对象。不写会报错,如:There is no PasswordEncoder mapped for the id ”null"
当然了,如果不想使用它自带的加密方式,也可以使用自己的。写一个类实现PasswordEncoder接口。
但是第二处,也就是UserDetailsService实现类中,返回User对象时,第二个形参其实最好不要再加密一次。这就不得不提这个User对象的作用了。总而言之,如果此时再加密,就相当于了解密,也就意味着数据库中必须是明文的形式。如果此时返回的User对象密码不加密,也就意味着数据库中的密码必须是密文的形式。实际开发中,肯定是希望数据库中的密码为密文了,这样更加安全。比如用户通过输入密码1234567,传到后台被spring security拦截,它首先通过配置文件中注入的PaswordEncoder的bean对象进行加密,然后内部会通过我们UserDetailsService实现类中查询数据库返回的User对象,进行用户名和密码进行比对。所以UserDetailsService实现类中的User对象不要进行加密了。
我们修改后重新测试一下,
先手动给数据库生成一个测试用户
只有用户自己知道真实的密码是多少,其他人仅通过数据库是无法知晓真实密码的。
然后修改UserDetailsService实现类
重启测试
假如我们使用数据库中存储的密文,进行登录,此时是不能登录成功的。
另外,我们不难发现,同一个字符串,通过加密生成的字符串每次都不一样,但是尽管每次都不一样,也都不会匹配失败。换句话说,同一个密码生成的密文每次都不一样,但是无论是哪个密文,最终都能解析成功。
七、用户注销
7.1 配置类中增加注销相关配置
//退出配置
http.logout().logoutUrl("/logout")//退出登录的处理接口随便写,系统帮你实现。和.loginProcessingUrl类似
.logoutSuccessUrl("/login.html")//退出成功跳转的页面或接口
.permitAll();
7.2 编写退出超链接
目的是通过点击超链接,跳转到配置类中设置的退出登录处理接口。
之前我们一直没写主页面,要不这次我们就编写一个主页面,然后在主页面完成退出超链接跳转吧。
7.3 测试
八、记住我功能的实现
什么是记住我功能?
比如说,我们进行登录之后,把浏览器关闭掉。下次再访问网站时,不需要重新进行登录。
比如说,163邮箱,它有一个十天内免登录,当我们勾选了以后,那么十天内都不用重新输入密码进行登录了。
正常而言,我们关闭浏览器后,默认cookie会消失,不信可以试试。
然后关闭浏览器,重新访问测试接口
提示我们需要重新登录
正常而言,我们关闭浏览器后,默认cookie会消失。但是如果设置了记住我,那么它会生成一个叫remember me的cookie,这个cookie包含了用户信息,且不会消失,直到我们设置的过期时间到了才会消失。
因此我们关闭后,再次请求,它会带着这个rememberme(就是token)到我们的服务器中查询建立的新的数据库表信息。【系统会在内存中自动创建表,但是处于安全考虑,我们一般会在自己的数据库建表。】
8.1 数据库建表
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;
8.2 编写配置类
当然也可以在原来那个配置类中写。也可以新建一个配置类。都可以。
/**
* 自动登录 配置类中,注入数据源和配置操作数据库对象
*/
//注入数据源
@Autowired
private DataSource dataSource;
//注入操作数据库的对象JdbcTokenRepositoryImpl,用它来创建token
// 当然最好选择返回上层接口。我们一般是这样的,因为多态方便后续修改维护。
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 赋值数据源
jdbcTokenRepository.setDataSource(dataSource);
// 自动创建表 , 第一次执行会创建,以后要执行就要删除掉!
//jdbcTokenRepository.setCreateTableOnStartup(true);//这里我们是自己创建的数据库。所以不要这句
return jdbcTokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
......
.and()
.rememberMe()//开启记住我功能
.tokenRepository(persistentTokenRepository())//把操作数据库对象传进来
.tokenValiditySeconds(60)//表示自动登录,60s内有效
.userDetailsService(userDetailsService)//查询数据库的service
......
}
8.3 完善登录页面
在登录页面添加复选框,要求name值必须为remember-me,否则SpringSecurity找不到。
8.4 测试
这样在60s内关闭浏览器后,重新打开是不需要登录的。只有超过这个时间才需要重新登录。
当每次自动登录时,会在浏览器中将token存cookie值。同时会在persistent_logins这张表中生成相应的数据。注意这些都是SpringSecurity帮我们实现的。
我们关闭浏览器,再次打开发现,不用我们登录了。这就是因为开启了remember-me。就是SpringSecurity帮我们存了token到cookie中。当然设置的过期时间到了还是要重新登录的,你也可以不设置过期时间,永不过期。但是不建议。
我们可以通过如下方法设置这个时间。比如说7天内免登录,那就是60*60*24*7
.tokenValiditySeconds(60*60*24*7)
九、CSRF功能
前面配置文件中,我们配置过一项,就是
那么什么是CSRF呢?
即跨站请求伪造(Cross-site request forgery)
跨站请求位置默认开启。针对 PATCH,POST,PUT 和 DELETE 方法进行防护。
想要实现该功能,只需要在配置类中开启CSRF的情况下,在前端中设置如下:
<input type="hidden"th:if="${_csrf}!=null"th:value="${_csrf.token}"name="_csrf"/>
一般我们测试的时候,免得在前端还要加上这个代码。都选择关闭CSRF功能。如果你不关闭,那么在前端表单登录的代码中一定要加上上面这段。否则你自己写的登录页面(属于跨站),POST提交就会被进行防护。
十、踢下线功能
10.1 核心代码
只需要在配置类中增加session相关配置
//踢下线配置
http.sessionManagement()
.maximumSessions(1) // 表示同一个用户最大登录客户端的数量为1
.maxSessionsPreventsLogin(false) // 阻止登录策略,如果为true,表示已经登录就不允许在别的地方登录了。如果为false,则表示在其他地方登录后,就会踢出之前其他地方登录的该账号。
.expiredSessionStrategy(new SessionInformationExpiredStrategy() {
// 方法一:页面跳转的方式处理
//private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
// 当发现session超时,或者session被踢下线之后,要进行的处理
//@Override
//public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
// redirectStrategy.sendRedirect(event.getRequest(),event.getResponse(),"/forced");
//}
// 方法二:前后端分离的情况下,一般是返回json数据
// 可以使用springboot默认的jackson的json处理对象,当然你也可以使用其他json工具
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>();
map.put("code",50009);
map.put("data",null);
map.put("msg","您已在其他地方进行了登录,请核实是否为本人操作!");
String json = objectMapper.writeValueAsString(map);
event.getResponse().setContentType("application/json;charset=utf-8");
event.getResponse().getWriter().write(json);
}
});
10.2 测试
先在谷歌浏览器上测试
换用其他浏览器登录同一账号
此时回到谷歌浏览器,点击刷新页面,就提示在其他地方进行了登录了。被迫下线!