SpringSecurity基础入门
一个身份认证、授权、防御常见攻击的框架。
spring security 中文网:Spring Security中文网
自定义配置
基于内存的用户认证
实现步骤如下:
- 在配置类中创建security的配置类:
@Configuration //声明当前类为配置类
@EnableWebSecurity //开启spring security的自定义配置
public class WebSecurityConfig {@Beanpublic UserDetailsService userDetailsService() {// 1、创建基于内存的用户信息管理器InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();// 3、将UserDetails对象交给基于内存的用户信息管理器管理manager.createUser(// 2、创建UserDetails对象,用于管理用户名、用户密码、用户角色、用户权限等内容User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build());// 当返回这个基于内存的用户信息管理器的时候,系统中默认的用户就被替换为了上面第二部中定义的UserDetails对象return manager;}
}
- 重启项目,在security默认提供的登录界面中,使用
UserDetails
中定义的用户信息登录。
实现原理分析:
- 应用程序启动时创建了
InMemoryUserDetailsManager
对象,该对象中管理了自定义的UserDetails
类的用户信息。 - 当访问web页面,校验用户信息的时候,security自动使用
InMemoryUserDetailsManager
的loadUserByUsername()
方法从内存中获取到UserDetails
对象,通过拿到的UserDetails
对象中定义的用户信息对web页面输入的信息进行校验。
基于数据库的数据源
在实际开发的过程中绝大多数情况都是需要基于数据库的数据源来做用户认证。
数据源实现案例如下:
- 在MySQL数据库中执行如下建表语句:
-- 创建数据库
CREATE DATABASE `security-demo`;
USE `security-demo`; -- 创建用户表
CREATE TABLE `user`( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(50) DEFAULT NULL, `password` VARCHAR(500) DEFAULT NULL, `enabled` BOOLEAN NOT NULL
); -- 唯一索引
CREATE UNIQUE INDEX `user_username_uindex` ON `user`(`username`); -- 插入用户数据,密码已经被加密,密码为abc(注意:这里密码是示例,实际加密密码应不同)
INSERT INTO `user` (`username`,`password`,`enabled`) VALUES
('admin','{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0Fxo/BTk76lW',TRUE),
('Helen','{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0Fxo/BTk76lW',TRUE),
('Tom','{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0Fxo/BTk76lW',TRUE);
- 引入依赖:
<dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.30</version>
</dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.4.1</version>
</dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
</dependency>
- 在
application.properties
配置文件中配置数据源信息:
#MySQL数据源
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security-demo
spring.datasource.username=root
spring.datasource.password=123456
#sql日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
- 创建实体类:
@Data
public class User {@TableId(value = "id",type = IdType.AUTO)private Integer id;private String username;private String password;private Boolean enabled;
}
- 创建
UserMapper
接口,继承BaseMapper
@Mapper
public interface UserMapper extends BaseMapper<User> {}
- 创建对应的
UserMapper.xml
文件
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="包名要对应到UserMapper.class"></mapper>
- 创建
UserService
接口,继承Iservice
接口
public interface UserService extends Iservice<User> {}
- 创建创建
UserService
的实现类UserServiceImpl
类,并继承ServiceImpl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper,User> implements UserService {}
- 创建
UserController
类,并注入UserService
@RestController
@RequestMapping("/user")
public class UserController {@AutoWiredprivate UserService userService;// 返回user的列表@GetMapping("/list")public List<User> getList(){return userService.list();}
}
到此数据源整合完毕。
注意:mybatis-plus可能与springboot存在版本冲突导致报错。
基于数据库的用户认证
上面已经创建了基于数据库的数据源的案例,我们继续上面的步骤,使用数据库来达到用户认证的目的。由于没有默认数据库实现,所以需要自己创建DBUserDetailsManager
对象,实现UserDetailsManager
、UserDetailsPasswordService
。
具体步骤如下:
- 创建
DBUserDetailsManager
对象,实现UserDetailsManager
、UserDetailsPasswordService
接口。并且实现这两个类中相关的抽象方法,空实现即可。
public class DBUserDetailsManager implements UserDetailsManager,UserDetailsPasswordService {}
- 在该类中注入
UserMapper
对象
@AutoWired
private UserMapper userMapper;
- 在该类的
loadUserByUsername
方法中根据username
获取UserDetails
对象
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 查询QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("username",username);User user = userMapper.selectOne(queryWrapper);if(user == null){// 如果没有查到,则抛出异常throw new UsernameNotFoundException(username);}else{// 创建权限列表Collection<GrantedAuthority> authorities = new ArrayList<>();// 查到了,组装UserDetails对象并返回return new org.springframework.security.core.userdetails.User(user.getUsername(),user.getPassword(),user.getEnabled(), //是否启用true, //用户账号是否过期true, //用户凭证是否过期true, //用户是否未被锁定authorities //权限列表);}
}
- 创建security的配置类
@Configuration //声明当前类为配置类
@EnableWebSecurity //开启spring security的自定义配置
public class WebSecurityConfig {@Beanpublic UserDetailsService userDetailsService() {// 1、创建基于数据库的用户信息管理器DBUserDetailsManager manager = new DBUserDetailsManager();return manager;}
}
- 重启项目,在security默认提供的登录界面中,使用数据库中定义的用户名,密码登录,测试功能。
默认配置
WebSecurityConfig
配置类中只包含了关于如何验证用户的信息。实际上,有一个配置类(称为 SecurityFilterChain
)在幕后被调用。它被配置为以下的默认实现。该默认配置是为了控制security中的一些默认的过滤器链以及他们的详细信息。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http// 开启授权保护.authorizeRequests(authorize -> authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated())// 自动使用表单授权方式(生成默认的登录等出页面).formLogin(withDefaults())// 使用基本授权方式(如果没有表单使用浏览器默认提供的).httpBasic(withDefaults());return http.build();
}
添加用户功能
在上面的DBUserDetailsManager
类中已经实现了createUser
方法,该方法就是添加用户的方法,我们可以通过该方法来实现添加用户的功能。
步骤如下:
- 在
UserController
类中创建新增用户的相关代码:
@PostMapping("/add")
public void add(@RequestBody User user){userService.saveUserDetails(user);
}
- 对
UserService
中的saveUserDetails
方法做实现:在UserService
中添加抽象方法
public void saveUserDetails(User user);
- 在
UserServiceImpl
中添加方法实现,将user对象包装为userDetails
对象交给DBUserDetailsManager
的createUser
方法处理
// 先将DBUserDetailsManager注入到UserServiceImpl中
@AutoWired
private DBUserDetailsManager dbUserDetailsManager;// 实现saveUserDetails方法
public void saveUserDetails(User user){UserDetails userDetails = org.springframework.security.core.userdetails.User.withDefaultPasswordEncoder().username(user.getUsername()).password(user.getPassword()).build();dbUserDetailsManager.createUser(userDetails);
}
- 在
DBUserDetailsManager
中为createUser
方法添加实现。
public void createUser(UserDetails userDetails){User user = new User();user.setUsername(userDetails.getUsername);user.setPassword(userDetails.getPassword);user.setEnabled(true);// 上面的步骤中已经注入了UserMapper对象,这里直接使用userMapper.insert(user);
}
- 重启应用程序,访问
UserController
中写的路径进行测试。
同样的原理,如果想要实现修改、删除用户也可以在DBUserDetailsManager
中的updateUser
、deleteUser
来做基于数据库的实现。
注意:
在测试的时候,可能会出现访问不进去的情况,这是因为Spring Security会防御常见攻击的原因,在他的web页面中会有一个name="_csrf"的隐藏表单,默认值为一串字符,在发起请求的时候会一同发回后端,从而实现对scrf攻击的防御手段。测试失败的原因就是因为测试的时候没有携带这一个字符串。解决方法是先将csrf攻击防御关掉:
在上面的默认配置部分讲解了SecurityFilterChain
,这里我们将SecurityFilterChain
添加到我们的WebSecurityConfig
配置类中,并在该方法中添加http.csrf(csrf->csrf.disable());
将csrf攻击防御关闭
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http// 开启授权保护.authorizeRequests(authorize -> authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated())// 自动使用表单授权方式(生成默认的登录等出页面).formLogin(withDefaults())// 使用基本授权方式(如果没有表单使用浏览器默认提供的).httpBasic(withDefaults());// 将csrf攻击防御关闭,有利于测试。http.csrf(csrf->csrf.disable());return http.build();
}
密码加密算法
以往密码都是以明文方式存储的,但是安全堪忧。为了解决安全问题发展出了密码加密。
Hash算法
Spring Security PasswordEncoder接口用于对密码进行单向转换,从而将密码安全地存储。对密码单向转换需要用到哈希算法,例如MD5、SHA-256、SHA-512等,哈希算法是单向的,只能加密,不能解密。
因此,敬据库中存储的是单向转换后的密码,Spring Security在进行用户身份验证时需要将用户输入的密码进行单向转换,然后与数据库的密码进行比较。
因此,如果发生数据泄露,只有密码的单向哈希会被暴露。由于哈希是单向的,并目在给定哈希的情况下只能通过暴力破解的方式猜测密码。
彩虹表
针对哈希加密算法,一些用户创建了一个名为‘彩虹表’的查找表。
彩虹表就是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合,有了它可以快速破解各类密码。越是复杂的密码,需要的彩虹表就越大,主流的彩虹表都是1B0G以上,目前主要的算法有LM,NTLM,MD5,SHA1,MYSQLSHA1,HALFLMCHALL,NTLMCHALL,ORACLE-SYSTEM,MD5-HALF.
加盐加密
为了减轻彩虹表的效果,开发人员开始使用加盐密码。不再只使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将一起经过哈希函数运算,生成一个唯一的哈希。盐将以明文形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,盐和用户输入的密码一起经过哈希函数运算,再与存储的密码进行比较。唯一的盐意味着彩虹表不再有效,因为对于每个盐和密码的组合,哈希都是不同的。
自适应单向函数
随着硬件的不断发展,加盐哈希也不再球全。原因是,计算机可以每秒执行数十亿次哈希计算。这意味着我们可以轻松地破解每个密码。
现在,开发人员开始使用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意占用资源(故意使用大量的CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,随着硬件的改进而增加。我们建议将“工作因子调整到系统中验证密码需要约一秒钟的时间。这种权衡是为了让攻击者难以破解密码。
自适应单向函数包括bcrypt
、PBKDF2
、scrypt和argon2
。
这些自适应单项函数都是PasswordEncoder
的实现类,也就是说想要使用这些函数可以使用PasswordEncoder
来创建它实现类的方式来使用这些函数。
密码加密算法体验
下面新建一个测试类对security的密码加密算法做一个体验:
创建一个SecurityDemoApplicationTests
的测试类,编写如下测试方法,并添加相应注解。
@SpringBootTest
class SecurityDemoApplicationTests {@Testvoid testPassword(){// 新建PasswordEncoder类的实现类BCryptPasswordEncodr实例// 参数部分为工作因子,最小值是4,默认值是10,最大值是31,值越大运算速度越慢PasswordEncoder encoder = new BCryptPasswordEncoder(4);// 明文:"password"// 密文:由于加盐加密每次加密生成的密文不相同// 调用encode方法对"password"进行加密String result = encoder.encode("password");System.out.println(result);// 密码校验Assert.isTrue(encoder.matches("password",result),"密码不一致");}
}
DelegatingPasswordEncoder
表中存储的密码形式:{bcrypt}一长串字符串
而存储密码中的前面的{bcrypt}部分是为了表明当前密码是使用那种PasswordEncoder
的实现类加密的。
这种存储格式的目的是,为了方便随时做密码策略的升级,兼容数据库中老版本密码生成策略生成的密文密码。
在DelegatingPasswordEncoder
类中有一个matches
方法,该方法就是比对明文密码和密文密码是否匹配的方法。而该方法中,会先将密文密码中的前缀取出用来根据这个前缀来生成明文密码的加密格式,将两个密文密码做比对,从而判断前端输入的密码是否正确。
自定义登录页面
下面是如何在前后端一体式项目中自定义登录界面的步骤:
- 创建一个
LoginController
类,在该类中编写login
方法,返回一个login视图
@Controller
public class LoginController {@GetMapping("/login")public String login(){return "login";}
}
- 在资源目录下创建一个
login.html
<html><body><h1>登录</h1><!-- th:if="${param.error}"作用:使用动态参数,表单会自动生成csrf字段,防止csrf攻击根据发布路径生成相对路径--><div th:if="${param.error}">错误的用户名和密码</div><form th:action="@{/login}" method="post"><div><input type="text" name="username" placeholder="用户名"></div><div><input type="password" name="password" placeholder="密码"></div><input type="submit" value="登录"></form></body>
</html>
- 在
WebSecurityConfig
配置文件中将formLogin中修改为自定义的配置:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http// 开启授权保护.authorizeRequests(authorize -> authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated())// 自动使用表单授权方式(生成默认的登录等出页面).formLogin(form->{// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error");})// 使用基本授权方式(如果没有表单使用浏览器默认提供的).httpBasic(withDefaults());return http.build();
}
- 重启项目,测试。
前后端分离
登录反馈
前后端分离开发中,在登录成功后只需要给前端返回json数据即可,而AuthenticationSuccessHandler
就是定义登录成功后如何给前端返回json信息的,而AuthenticationFailureHandler
是定义登录失败后信息的。
步骤如下:
- 由于最后要返回给前端一个json格式的数据,所以先导入fastjson依赖
<dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.37</version>
</dependency>
- 在配置类中实现
AuthenticationSuccessHandler
接口,并实现其中的方法
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {public void onAuthenticationSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication) throws IOException{Object principal = authentication.getPrincipal(); // 获取用户身份信息// Object credentials = authentication.getCredentials(); // 获取用户凭证信息// Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); //获取用户权限信息HashMap result = new HashMap();result.put("code",0);result.put("message","登录成功");result.put("data",principal);// 将对象转换为json字符串String json = JSON.toJSONString(result);// 返回json数据到前端response.setContentType("application/json;charset=UTF-8");response.getWriter(json);}
}
- 将
MyAuthenticationSuccessHandler
类在WebSecurityConfig
配置类中进行配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {// 开启授权保护http.authorizeRequests(authorize -> {authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated();});// 使用表单授权方式http.formLogin(form -> {// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error")// 认证成功处理.successHandler(new MyAuthenticationSuccessHandler());});// 关闭csrf攻击防御http.csrf(csrf -> csrf.disable());return http.build();
}
- 在配置类中实现
AuthenticationFailureHandler
接口,并实现其中的方法
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {public void onAuthenticationFailure(HttpServletRequest request,HttpServletResponse response,AuthenticationException exception) throws IOException{// 获取本地信息String localizedMessage = exception.getLocalizedMessage();HashMap result = new HashMap();result.put("code",-1);result.put("message",localizedMessage);// 将对象转换为json字符串String json = JSON.toJSONString(result);// 返回json数据到前端response.setContentType("application/json;charset=UTF-8");response.getWriter(json);}
}
- 将
MyAuthenticationFailureHandler
类在WebSecurityConfig
配置类中进行配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {// 开启授权保护http.authorizeRequests(authorize -> {authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated();});// 使用表单授权方式http.formLogin(form -> {// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error")// 认证成功处理.successHandler(new MyAuthenticationSuccessHandler())// 认证失败处理.failureHandler(new MyAuthenticationFailureHandler());});// 关闭csrf攻击防御http.csrf(csrf -> csrf.disable());return http.build();
}
- 重新启动项目,测试。发现登录失败后前端会返回自定义的json格式数据
注销反馈
目前注销的时候还没有返回给前端结果,注销时候的处理类为LogoutSuccessHandler
。
实现注销反馈信息的步骤如下:
- 创建配置类实现
LogoutSuccessHandler
类,实现该类的方法。
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {public void onLogoutSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication){HashMap result = new HashMap();result.put("code",0);result.put("message","注销成功");// 将对象转换为json字符串String json = JSON.toJSONString(result);// 返回json数据到前端response.setContentType("application/json;charset=UTF-8");response.getWriter(json);}
}
- 将
MyLogoutSuccessHandler
类在WebSecurityConfig
配置类中进行配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {// 开启授权保护http.authorizeRequests(authorize -> {authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated();});// 使用表单授权方式http.formLogin(form -> {// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error")// 认证成功处理.successHandler(new MyAuthenticationSuccessHandler())// 认证失败处理.failureHandler(new MyAuthenticationFailureHandler());});// 登出http.logout(logout -> {logout.logoutSuccessHandler(new MyLogoutSuccessHandler);});// 关闭csrf攻击防御http.csrf(csrf -> csrf.disable());return http.build();
}
- 重新启动项目,在注销以后就会看到后端发回的注销成功界面
请求未认证的接口
请求未认证接口:当访问一个需要认证之后才能访问的接口的时候,Spring Security会使用AuthenticationEntryPoint
将用户请求跳转到登录页面,要求用户提供登录凭证。
这里我们也希望系统返回json结果,因此我们定义类实现AuthenticationEntryPoint
接口。
实现步骤如下:
- 创建配置类实现
AuthenticationEntryPoint
接口,并实现其中的方法。
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {public void commence(HttpServletRequest request,HttpServletResponse response,AuthenticationException authException) throws IOException{String localizedMessage = "需要登录才能访问该资源";HashMap result = new HashMap();result.put("code",-1);result.put("message",localizedMessage);// 将对象转换为json字符串String json = JSON.toJSONString(result);// 返回json数据到前端response.setContentType("application/json;charset=UTF-8");response.getWriter(json);}
}
- 将
MyAuthenticationEntryPoint
类在WebSecurityConfig
配置类中进行配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {// 开启授权保护http.authorizeRequests(authorize -> {authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated();});// 使用表单授权方式http.formLogin(form -> {// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error")// 认证成功处理.successHandler(new MyAuthenticationSuccessHandler())// 认证失败处理.failureHandler(new MyAuthenticationFailureHandler());});// 登出http.logout(logout -> {logout.logoutSuccessHandler(new MyLogoutSuccessHandler);}); // 异常情况处理http.exceptionHandling(exception -> {// 请求未认证exception.authenticationEntryPoint(new MyAuthenticationEntryPoint);});// 关闭csrf攻击防御http.csrf(csrf -> csrf.disable());return http.build();
}
- 重新启动,访问web页面测试功能。
跨域问题
跨域全称是跨域资源共享(Cross-Origin Resources Sharing,.CoRS),它是浏览器的保护机制,只允许网页请求统一域名下的服务,同一域名指=>协议、域名、端口号都要保持一致,如果有一项不同,那么就是跨域请求。在前后端分离的项目中,需要解决跨域的问题。
在security中解决跨域问题很简单,只需要在配置类中添加http.cors(withDefaults());
即可,具体如下:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {// 开启授权保护http.authorizeRequests(authorize -> {authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated();});// 使用表单授权方式http.formLogin(form -> {// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error")// 认证成功处理.successHandler(new MyAuthenticationSuccessHandler())// 认证失败处理.failureHandler(new MyAuthenticationFailureHandler());});// 登出http.logout(logout -> {logout.logoutSuccessHandler(new MyLogoutSuccessHandler);}); // 异常情况处理http.exceptionHandling(exception -> {// 请求未认证exception.authenticationEntryPoint(new MyAuthenticationEntryPoint);});// 解决跨域请求http.cors(withDefaults());return http.build();
}
身份认证
获取用户相关信息
在Spring Security框架中,SecurityContextHolder
、SecurityContext
、Authentication
、Principal
和Credential
是一些与身份验证和授权相关的重要概念。它们之间的关系如下:
SecurityContextHolder
:SecurityContextHolder
是Spring Security存储已认证用户详细信息的地方。SecurityContext:SecurityContext
是从SecurityContextHolder
获取的内容,包含当前已认证用户的Authentication
信息。Authentication
:Authentication
表示用户的身份认证信息。它包含了用户的Principal
、Credentials
和Authorities
信息。
实际使用实例如下:
@RestController
public class IndexController {@GetMapping("/getUserData")public Map getUserData(){// 首先获取到SecurityContext,SecurityContext中包含了已认证的用户信息SecurityContext context = SecurityContextHolder.getContext();// 拿到用户信息AuthenticationAuthentication authentication context.getAuthentication();// 通过Authentication拿到用户的Principal、Credential和Authority信息Object principal = authentication.getPrincipal();Object credentials = authentication.getCredentials();// 获取到权限信息Cpllection<? extends GrantedAuthority> authorities = authentication.getAuthorities();// 通过authentication拿到用户名String username = authentication.getUsername();HashMap hashMap = new HashMap();hashMap.put("username",username);hashMap.put("authorities",authorities);return hashMap;}
}
重启项目后,访问前端的该路径就会看到前端呈现的json格式的用户名信息和权限信息。
会话并发处理
针对同一个账号后登录的账号会使先登录的账号失效,代码示例如下:
- 实现接口
SessionInformationExpiredStrategy
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException,ServletException {HashMap result = new HashMap();result.put("code",-1);result.put("message","该账号已在其他设备登录");String json = JSON.toJSONString(result);HttpServletResponse response = event.getResponse();response.setContentType("application/json;chartset-UTF-8");response.getWrite().println(json);}
}
- 将
MySessionInformationExpiredStrategy
类在WebSecurityConfig
配置类中进行配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {// 开启授权保护http.authorizeRequests(authorize -> {authorize// 对所有请求开启授权保护.anyRequest()// 已经认证的请求会被自动授权.authenticated();});// 使用表单授权方式http.formLogin(form -> {// 重定向到"/login"页面form.loginPage("/login")// 设置无需授权即可访问.permitAll()// 设置账号和密码对应输入框的name.usernameParameter("username").passwordParameter("password")// 登录失败后的url地址,默认为"/longin?error".failureUrl("/longin?error")// 认证成功处理.successHandler(new MyAuthenticationSuccessHandler())// 认证失败处理.failureHandler(new MyAuthenticationFailureHandler());});// 登出http.logout(logout -> {logout.logoutSuccessHandler(new MyLogoutSuccessHandler);}); // 异常情况处理http.exceptionHandling(exception -> {// 请求未认证exception.authenticationEntryPoint(new MyAuthenticationEntryPoint);});// 解决跨域请求http.cors(withDefaults());// 会话http.sessionManagement(session -> {// 会话并发处理,参数说明允许同一个账号同时在一台设备登录session.maximumSessions(1).expiredSessionStrategy(new MySessionInformationExpiredStrategy());});// 关闭csrf攻击防御http.csrf(csrf -> csrf.disable());return http.build();
}
- 重启项目后,在两个不同的浏览器登录的时候发现只能在其中一个登录
授权
授权管理的实现在SpringSecurity中非常灵活,可以帮助应用程序实现以下两种常见的授权需求:
- 用户权限资源:例如某个用户的权限是添加用户、查看用户列表,而另一个用户的权限是查看用户列表
- 用户角色权限资源:例如某个用户的角色是管理员、另一个用户的角色是普通用户,管理员能做所有操作,普通用户只能查看信息
基于Request的授权案例
用户-权限-资源
需求:
- 具有USER_LIST权限的用户可以访问/user/list接口
- 具有USER_ADD权限的用户可以访问/user/add接口
实现步骤:
- 在SecurityFilterChain方法中添加授权保护的配置
// 开启授权保护
http.authorizeRequests(authorize -> {// 具有USER_LIST权限的用户可以访问/user/listauthorize.requestMatchers("/user/list").hasAuthority("USER_LIST")// 具有USER_ADD的用户可以访问/user/add.requestMatchers("/user/add").hasAuthority("USER_ADD")// 对所有请求开启授权保护.anyRequest()// 已认证的请求会被自动授权.authenticated();
});
- 由于数据库没有添加相关的权限字段,所以我们在
DBUserDetailsManage
类中的loadUserByUsername
方法中修改代码,采用硬编码的方式提供权限。
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 查询QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("username",username);User user = userMapper.selectOne(queryWrapper);if(user == null){// 如果没有查到,则抛出异常throw new UsernameNotFoundException(username);}else{// 创建权限列表Collection<GrantedAuthority> authorities = new ArrayList<>();// 向权限列表中添加权限authorities.add(new GrantedAuthority(){public String getAuthority(){return "USER_LIST"}});authorities.add(new GrantedAuthority(){public String getAuthority(){return "USER_ADD"}});// 查到了,组装UserDetails对象并返回return new org.springframework.security.core.userdetails.User(user.getUsername(),user.getPassword(),user.getEnabled(), //是否启用true, //用户账号是否过期true, //用户凭证是否过期true, //用户是否未被锁定authorities //权限列表);}
}
- 重启项目,在登陆后访问
/user/list
和/user/add
,发现正常访问。之后回到loadUserByUsername
方法中,把向权限列表中添加某个权限的代码注释以后,再重启代码并再次访问这两个路径,就会发现被注释掉的权限生效。 - 当访问未授权页面的时候返回403的页面,对用户很不友好。可以在
SecurityFilterChain
方法中异常情况处理部分添加请求未授权的接口时候的响应,代码如下:
// 异常情况处理
http.exceptionHandling(exception -> {// 请求未认证exception.authenticationEntryPoint(new MyAuthenticationEntryPoint);// 这里直接用匿名内部类的方式,就不再新建一个类了exception.accessDeniedHandler((request,response,e) -> {//创建结果对象HashMap result = new HashMap();result.put("code",-1);result.put("message","没有权限访问");// 将结果对象转换未json字符串String json = JSON.toJSONString(result);// 返回响应response.setContentType("application/json;charset=UTF-8");response.getWriter().println(json);});
});
- 之后重启项目,再次访问该路径,就会发现返回的json格式的数据。
用户-角色-资源
需求:角色为ADMIN
的用户才可以访问/user/**
路径下的资源
实现步骤如下:
- 在
SecurityFilterChain
方法中添加如下配置替换掉原有的授权保护配置
// 开启授权保护
http.authorizeRequests(authorize -> {// 具有ADMIN身份的用户可以访问/user/**authorize.requestMatchers("/user/**").hasRole("ADMIN")// 对所有请求开启授权保护.anyRequest()// 已认证的请求会被自动授权.authenticated();
});
- 由于数据库中也没有用户身份的相关字段,所以我们在
DBUserDetailsManage
类中的loadUserByUsername
方法中修改代码,采用硬编码的方式提供用户角色。
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 查询QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("username",username);User user = userMapper.selectOne(queryWrapper);if(user == null){// 如果没有查到,则抛出异常throw new UsernameNotFoundException(username);}else{// 由于通过org.springframework.security.core.userdetails.User的构造方法没有办法添加角色,所以我们采用如下方法来做return org.springframework.security.core.userdetails.User// 用户名.withUsername(user.getUsername())// 密码.password(user.getPassword())// 用户是否禁用.disabled(!user.getEnabled())// 是否过期.credentialsExpired(false)// 用户是否被锁定.accountLocked(false)// 为该用户配置一个角色.roles("ADMIN").bulid();}
}
- 重新启动应用程序,测试功能。
用户-角色-权限-资源
RBAC(Role-Based Access Control,基于角色的访问控制)是一种常用的数据库设计方案,它将用户的权限分配和管理与角色相关联。以下是一个基本的RBAC数据库设计的示例:
- 用户表:包含用户的基本信息,例如用户名、密码和其他身份信息
列名 | 数据类型 | 描述 |
---|---|---|
user_id | int | 用户id |
username | varchar | 用户名 |
password | varchar | 密码 |
varchar | 电子邮件地址 | |
… | … | … |
- 角色表:存储所有可能的角色及其描述
列名 | 数据类型 | 描述 |
---|---|---|
role_id | int | 角色id |
role_name | varchar | 角色名称 |
description | varchar | 角色描述 |
… | … | … |
- 权限表:定义系统中所有可能的权限
列名 | 数据类型 | 描述 |
---|---|---|
permission_id | int | 权限id |
permission_name | varchar | 权限名称 |
description | varchar | 权限描述 |
… | … | … |
- 用户角色关联表:将用户与角色关联起来
列名 | 数据类型 | 描述 |
---|---|---|
user_role_id | int | 用户角色关联id |
user_id | int | 用户id |
role_id | int | 角色id |
… | … | … |
- 角色权限关联表
列名 | 数据类型 | 描述 |
---|---|---|
role_permission_id | int | 角色权限关联id |
role_id | int | 角色id |
permission_id | int | 权限id |
… | … | … |
基于方法的授权
也就是在开启基于方法的授权以后,在controller类中通过在方法上添加注解的方式实现基于方法的授权。
步骤如下:
- 在WebSecurityConfig配置类种添加
@EnableMethodSecurity
注解开启方法授权,并删除filterChain
中的授权的配置
@Configuration //声明当前类为配置类
@EnableWebSecurity //开启spring security的自定义配置
@EnableMethodSecurity //开启基于方法的授权
public class WebSecurityConfig {}
- 给用户授予角色和权限,在
DBUserDetailsManager
中的loadUserByUsername
方法
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 查询QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("username",username);User user = userMapper.selectOne(queryWrapper);if(user == null){// 如果没有查到,则抛出异常throw new UsernameNotFoundException(username);}else{// 由于通过org.springframework.security.core.userdetails.User的构造方法没有办法添加角色,所以我们采用如下方法来做return org.springframework.security.core.userdetails.User// 用户名.withUsername(user.getUsername())// 密码.password(user.getPassword())// 用户是否禁用.disabled(!user.getEnabled())// 是否过期.credentialsExpired(false)// 用户是否被锁定.accountLocked(false)// 为该用户配置一个角色.roles("ADMIN")// 添加单个权限 .authorities("权限名")// 添加多个权限 .authorities("权限名1","权限名2",...,"权限名n")// 如果想要添加多个,必须得按照该格式写,如果写多个.authorities("权限名"),后面的权限会把前面的权限覆盖。而且它还会覆盖角色.bulid();}
}
- 在controller类中的某个方法上添加
@PreAuthorize
注解
@RestController
@RequestMapping("/user")
public class UserController {@AutoWiredprivate UserService userService;@GetMapping("/list")// 这个注解表示只有ADMIN这个角色才能访问该资源@PreAuthorize("hasRole('ADMIN')")public List<User> getList(){}
}
- 重新启动程序,测试相关功能。
- 如果没有配置注解的话,默认都可以访问。但是如果添加了
@PreAuthorize
注解的话,只有满足注解中条件的用户才能访问。 @PreAuthorize
注解中还可以配置较为复杂的参数,例如:@PreAuthorize("hasRole('ADMIN') and authentication.name=='admin'")
。这个注解代表角色为ADMIN且用户名为admin的用户才能访问该资源。- 上面是角色-方法。如果想要配置权限-方法的话。可以在controller方法上添加
@PreAuthorize("hasAuthority('权限名')")
。这个注解可以配置哪种权限可以访问该资源。
OAuth2
OAuth2中分为四个角色:
- 资源所有者
- 客户应用
- 资源服务器
- 授权服务器
客户应用访问资源服务器需要令牌,这个令牌需要资源拥有者授权后从授权服务器获取,获取到令牌后,客户应用就可以使用令牌从资源服务器拿到想要的资源。
在以往的情况下,授权和校验都是在同一个后端代码中完成的。而OAuth2是把授权和校验分到了两个服务器来完成。
例如某些网站使用第三方账号的授权登录。
四种授权模式
四种授权模式:
- 授权码(authorization-code)
- 隐藏式(implicit)
- 密码式(password)
- 客户端凭证(client credentials)
授权码
授权码(authorization-code),指的是第三方应用先申请一个授权码,再通过该授权码获取令牌。
这种方式是最常用,最复杂,也是最安全的,它适用于那些有后端的Wb应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
过程:
- 发起授权请求
- A服务将用户重定向到B服务的授权端点(如/oauth/authorize)。
- 重定向时,A服务传递一些参数,如client_id(客户端ID)、response_type=code(表示使用授权码模式)、redirect_uri(回调地址)等。
- 用户同意授权
- 用户在B服务的授权页面上看到A服务的请求,并决定是否授权。
- 如果用户同意授权,B服务将生成一个授权码,并将其重定向回A服务指定的redirect_uri,同时带上授权码和其他可能的参数(如state)。
- 客户端请求访问令牌
- A服务在收到B服务的重定向请求后,从URL参数中提取授权码。
- A服务向B服务的令牌端点(如/oauth/token)发送一个请求,以获取访问令牌(token)。
- 请求中包含的参数有:grant_type=authorization_code(表示使用授权码模式)、code(从B服务获取的授权码)、redirect_uri(之前注册的回调地址,用于验证回调地址的正确性)、client_id和client_secret(用于验证客户端的身份)。
- 资源服务器返回访问令牌
- 如果验证成功,B服务将返回一个包含访问令牌(token)的响应。
- 访问令牌是客户端用于访问资源服务器上用户资源的凭证。
- 响应中可能还包含刷新令牌(refresh token),用于在访问令牌过期后获取新的访问令牌。
- 客户端使用访问令牌访问资源
- A服务使用从B服务获取的访问令牌来访问B服务上的用户资源。
- 在HTTP请求的头部(如Authorization头)中包含访问令牌。
- B服务验证访问令牌的有效性,并返回相应的资源给A服务。
隐藏式
OAuth2的隐藏式授权方式(也称为隐式授权模式或简化模式)是一种授权流程,它允许客户端应用程序直接从授权服务器获取访问令牌(Access Token),而无需与后端服务器进行交互。这种方式主要用于没有后端服务器的客户端应用程序,如单页面应用(SPA)或移动应用。
这种方式允许直接向前端颁发令牌。这种方式没有授权码这个步骤。
过程:
- 客户端发起请求:
- 客户端将用户重定向到授权服务器的授权端点。
- 重定向URL中包含客户端的client_id、请求的response_type=token(表示使用隐式授权模式)、redirect_uri(回调地址)等参数。
- 用户授权:
- 用户在授权页面上登录(如果尚未登录),并决定是否授权客户端访问其资源。
- 如果用户同意授权,授权服务器将直接生成访问令牌,并将其附加到重定向URI的片段(fragment)部分,然后重定向用户回客户端的redirect_uri。
- 客户端处理响应:
- 客户端在redirect_uri中接收到包含访问令牌的响应。
- 由于访问令牌在URL的片段部分,因此它不会暴露在浏览器的历史记录或服务器日志中,增加了安全性。
- 客户端从URL片段中提取访问令牌,并使用它访问受保护的资源。
密码式
OAuth2的密码式授权方式(也称为“Resource Owner Password Credentials Grant”)允许客户端应用程序在获得用户的用户名和密码后,直接向授权服务器请求访问令牌(Access Token)。这种方式通常用于那些用户高度信任客户端,并愿意直接提供其凭据的场景。
这种方式需要用户给出自己的用户名/密码,显然风险很大,因此只适用于其他授权方式都无法采用的情况,而且必须是用户高度信任的应用。
过程:
- 用户提供凭据
- 用户直接向客户端应用程序提供其用户名和密码。
- 客户端应用程序需要确保用户明白其正在将凭据提供给该应用程序,并且用户需要明确授权应用程序使用该凭据进行身份验证。
- 客户端发送请求
- 客户端应用程序将用户名和密码,以及其他必要的参数(如client_id、client_secret、grant_type=password等),打包成一个HTTP请求,发送到授权服务器的令牌端点(Token Endpoint)。
- client_id和client_secret用于标识客户端应用程序的身份,并证明其有权请求访问令牌。
- grant_type=password表明这是一个密码式授权请求。
- 授权服务器验证凭据
- 授权服务器接收到请求后,会验证提供的用户名和密码的有效性。
- 如果凭据验证通过,授权服务器将生成一个访问令牌,并可能包括一个刷新令牌(Refresh Token),然后将其返回给客户端。
- 客户端使用访问令牌
- 客户端收到访问令牌后,可以在后续与资源服务器的交互中使用该令牌来访问受保护的资源。
- 访问令牌通常包含在HTTP请求的Authorization头部中,以“Bearer”为类型标识符,如“Authorization: Bearer ”。
客户端凭证
OAuth2的客户端凭证授权方式(Client Credentials Grant)是一种适用于机器到机器(M2M)通信的授权流程,特别是在客户端需要代表自身(而不是用户)访问受保护的资源时。适用于没有前端的命令行应用,即在命令行下请求令牌。
这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。
步骤:
- 客户端请求
- 客户端向授权服务器的令牌端点发送请求,请求中包含以下参数:
- grant_type:指定授权类型,对于客户端凭证模式,其值为client_credentials。
- client_id:客户端的ID,用于标识客户端的身份。
- client_secret:客户端的密钥,用于验证客户端的身份。
- scope(可选):定义客户端可以访问的资源的范围。
- 客户端向授权服务器的令牌端点发送请求,请求中包含以下参数:
- 授权服务器验证
- 授权服务器验证客户端ID和客户端密钥的有效性。
- 如果验证通过,授权服务器将生成一个访问令牌(Access Token)。
- 返回访问令牌
- 授权服务器将访问令牌返回给客户端。访问令牌是客户端用于访问受保护资源的凭证。
授权模式的选择
spring中的实现
Spring Security:
- 客户应用(OAuth2 Client):OAuth2客户端功能中包含OAuth2 Login
- 资源服务器(OAuth2 Resource Server)
Spring:
- 授权服务器(Spring Authorization Server):它是在Spring Security之上的一个单独的项目
相关依赖如下:
<!-- 资源服务器 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency><!-- 客户应用 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency><!-- 授权服务器 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
授权登录的实现思路
找到某第三方的OAuth2的第三方授权的配置界面完成相应的配置。
代码实现,之后自行搜索。