Spring Security系列之一 简单介绍和实战
Spring Security系列之一 简单介绍和实战
鉴于市面上的spring security教程参差不齐,要么一来就直接分析源码,要么就只是贴出一些代码说就这么配置就行了,以至于具体功能实现之后对security还是一知半解,本人也深受其害,所以决心搞一个spring security的系列,循序渐进,由浅入深。
章节
Spring Security系列之一 简单介绍和实战
Spring Security系列之四 前后端分离项目用jwt做认证
Spring Security系列之五 前后端分离项目用户授权
上面是我自己计划的章节,可能会变也可能不会变,先挖坑,后面再慢慢补。
Spring Security是为基于Spring的应用程序提供声明式安全保护的安全性框架。Spring Security提供了完整的安全性解决方案,包括用户认证( Authentication )和用户权限( Authorization )两部分。用户认证就是确认某个用户是否有进入系统的权限,一般使用用户名密码认证,也就是登录。用户权限就是确定哪些用户可以访问哪些资源。
应用场景
使用Spring Security的原因有很多,大部分都是因为JavaEE规范中缺乏安全相关的功能,同时安全相关的功能要移植另外一套应用程序,也需要大量的工作去做重新适配,而spring security解决了这些问题,它提供了很多有用的、可定制的安全功能。
实战
先看看一个最最基本的security演示,都2021年了,项目当然要使用springboot来构建。
要在springboot中使用security,只需要引入对应的starter依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
然后在配置文件中先自定义一个用户名密码:
server:
port: 8080
spring:
security:
user:
name: user
password: 123456
新建一个index.html文件,用于测试登录和未登录的情况下访问:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<p>hello spring security</p>
</body>
</html>
启动项目,访问http://localhost:8080,spring security已经生效了,默认拦截全部请求,这个时候没有登录,会跳转到内置登录页面:
输入账号和密码后会跳转到index.html。
上面的项目就是一个最简单的实现了,当然,它非常不完善,存在几个问题:
- 当什么也没有配置的时候,账号和密码是由Spring Security定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。
- 登录界面是内置的,如果说登录过程中还有什么要验证的,登录成功之后跳转哪个页面,这些肯定是需要自定义的。
针对第一个问题,我们需要自定义控制认证逻辑,只需要实现UserDetailsService接口即可。
接口定义如下:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
我们实现这个接口方法,username就是前端传过来的,我们需要在数据库中查找出这个用户,并且将这个用户包装成UserDetails
对象返回给security
就可以了。
UserDetails
也是一个接口,定义了用户相关的信息:
public interface UserDetails extends Serializable {
/**
* 返回授予用户的权限,不能为空
* @return the authorities, sorted by natural key (never <code>null</code>)
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 用户的密码
* @return the password
*/
String getPassword();
/**
* 用户的用户名
* @return the username (never <code>null</code>)
*/
String getUsername();
/**
* 用户的帐户是否已过期。过期的帐户无法通过身份验证
*/
boolean isAccountNonExpired();
/**
* 用户是锁定还是解锁。锁定的用户无法通过身份验证
* @return 没有锁定返回true
*/
boolean isAccountNonLocked();
/**
* 用户的凭据(密码)是否已过期。过期的凭据会阻止身份验证
*/
boolean isCredentialsNonExpired();
/**
* 启用还是禁用用户。禁用的用户无法通过身份验证
*/
boolean isEnabled();
}
这个接口有两个实现类:
其中User类只是定义了一些尝龟的属性,和UserDetails中的方法对应:
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
public User(String username, String password, boolean enabled, boolean accountNonExpired,
boolean credentialsNonExpired, boolean accountNonLocked,
Collection<? extends GrantedAuthority> authorities) {
Assert.isTrue(username != null && !"".equals(username) && password != null,
"Cannot pass null or empty values to constructor");
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
}
}
//...省略
MutableUser类为一个包装类,他包含了一个password属性,通过它来包装一次修改用户密码:
class MutableUser implements MutableUserDetails {
private String password;
private final UserDetails delegate;
MutableUser(UserDetails user) {
this.delegate = user;
this.password = user.getPassword();
}
}
//...省略
上面userdetail实现很多时候满足不了我们的需求,所以一般都需要自定义一个UserDetails
的实现类。
密码加密
上面说的是把用户从数据库中查出来了,但是用户的密码没有对比,所以必然存在一个密码解析对比的过程。
而在Spring Security要求容器中必须有PasswordEncoder实例(客户端密码和数据库密码是否匹配是由Spring Security 去完成的,Security中还没有默认密码解析器)。所以当自定义登录逻辑时要求必须给容器注入PaswordEncoder的bean对象。
PasswordEncoder接口定义如下:
public interface PasswordEncoder {
/**
* 编码原始密码。通常,良好的编码算法将SHA-1或更大的哈希值与8字节或更大的随机生成的盐结合使用
*/
String encode(CharSequence rawPassword);
/**
* 验证从存储中获取的编码密码,也对提交的原始密码进行编码。如果密码匹配,则返回true;否则,返回false。存储的 * 密码本身不会被解码。
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* 如果需要再次对编码后的密码进行编码以提高安全性,则返回true,否则返回false。默认实现始终返回false
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
security中内置了很多解析器:
其中,BCryptPasswordEncoder
是spring security官方推荐的解析器,它是对bcrypt强散列方法的具体实现,基于Hash算法实现的单向加密。可以通过strength控制加密强度,默认为10,长度越长安全性越高。
Bcrypt 有两个特点:
- 每一次 HASH 出来的值不一样
- 计算非常缓慢
因此使用 Bcrypt 进行加密后,攻击者破解密码成本变得不可接受,但代价是应用自身也会性能受到影响,不过登录行为并不是随时在发生,因此能够忍受。
使用也很简单:
@SpringBootTest
@Slf4j
class SecurityApplicationTests {
@Test
void testPasswordEncoder(){
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String encode = encoder.encode("123456");
log.info("编码后的密码:{}, 密码是否正确:{}",encode,encoder.matches("123456",encode));
}
}
需要注意的是,Spring Security要求:当进行自定义登录逻辑时容器内必须有PasswordEncoder实例。所以需要编写一个配置类,将密码解析器先注入进去:
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder getPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
自定义登录
上面讲解的UserDetailsService和PasswordEncoder在自定义登录逻辑时都需要用到,对于登录,首先我们需要设置完成数据库原型设计。
数据库表结构
根据RBAC思想设计数据库表,下面是ER图:
数据库脚本:
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `power`;
CREATE TABLE `power` (
`id` int NOT NULL AUTO_INCREMENT,
`title` varchar(32) NOT NULL,
`url` varchar(64) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='权限';
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` int NOT NULL AUTO_INCREMENT,
`role_name` varchar(32) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `role` VALUES ('1', 'admin');
INSERT INTO `role` VALUES ('2', 'normal_user');
DROP TABLE IF EXISTS `role_power`;
CREATE TABLE `role_power` (
`id` int NOT NULL AUTO_INCREMENT,
`role_id` int NOT NULL,
`power_id` int NOT NULL,
PRIMARY KEY (`id`),
KEY `role_power___fk_power_id` (`power_id`),
KEY `role_power___fk_role_id` (`role_id`),
CONSTRAINT `role_power___fk_power_id` FOREIGN KEY (`power_id`) REFERENCES `power` (`id`),
CONSTRAINT `role_power___fk_role_id` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(64) NOT NULL,
`password` varchar(64) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `user` VALUES ('1', 'test1', '$2a$10$pjHyw9MSGC/i6k546Ii/0uLFgTK4WYB4.8bSRq7yB4dy.ZpBLxOha');
INSERT INTO `user` VALUES ('2', 'test2', '$2a$10$pjHyw9MSGC/i6k546Ii/0uLFgTK4WYB4.8bSRq7yB4dy.ZpBLxOha');
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`role_id` int NOT NULL,
UNIQUE KEY `user_role_pk` (`id`),
KEY `user_role___fk_role_id` (`role_id`),
KEY `user_role___fk_user_id` (`user_id`),
CONSTRAINT `user_role___fk_role_id` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`),
CONSTRAINT `user_role___fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `user_role` VALUES ('1', '1', '1');
INSERT INTO `user_role` VALUES ('2', '1', '2');
INSERT INTO `user_role` VALUES ('3', '2', '2');
配置mybatis
添加依赖:
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
新建一个user类,并实现UserDetails接口:
public class User implements UserDetails {
private Integer id;
private String username;
private String password;
private List<Role> roleList;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roleList.stream().map(role ->
new SimpleGrantedAuthority(role.getRoleName())).collect(Collectors.toList());
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
新建mapper:
public interface UserDao {
User queryByName(String name);
}
对应的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="com.lin.security.dao.UserDao">
<resultMap type="com.lin.security.entity.User" id="UserMap">
<result property="id" column="id" jdbcType="INTEGER"/>
<result property="username" column="username" jdbcType="VARCHAR"/>
<result property="password" column="password" jdbcType="VARCHAR"/>
<collection property="roleList" ofType="com.lin.security.entity.Role">
<result property="id" column="rid" jdbcType="INTEGER"/>
<result property="roleName" column="role_name" jdbcType="VARCHAR"/>
</collection>
</resultMap>
<select id="queryByName" parameterType="java.lang.String" resultMap="UserMap">
select u.*, r.id as rid, r.role_name
from security.user u
left join user_role ur on u.id = ur.user_id
inner join role r on ur.role_id = r.id
where username = #{name}
</select>
</mapper>
自定义登录的service逻辑,实现UserService接口:
@Service("userService")
@Slf4j
public class UserServiceImpl implements UserService {
@Resource
private UserDao userDao;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userDao.queryByName(username);
if (user == null){
throw new UsernameNotFoundException("用户名错误");
}
return user;
}
}
我们这儿只需要将用户查询出来,密码的验证交给security来完成就行了。
自定义前端页面
第一个问题解决了,接下来看看第二个问题。
我们新建三个页面,登录页面、登录成功之后跳转的页面,登录失败显示的页面。
login.html:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>login</title>
</head>
<body>
<form action="/login" method="post">
<input type="text" name="username"/>
<input type="password" name="password"/>
<input type="submit" value="提交"/>
</form>
</body>
</html>
index.html:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<div>
<p>hello spring security</p>
<p>用户名:<span th:text="${user.username}"></span></p>
</div>
</body>
</html>
failure.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>failure</title>
</head>
<body>
<div>
<p>用户民或密码错误</p>
</div>
</body>
</html>
security配置
配置spring security需要继承WebSecurityConfigurerAdapter
类,重写以下三个方法:
protected void configure(AuthenticationManagerBuilder auth) throws Exception {}
public void configure(WebSecurity web) throws Exception {}
protected void configure(HttpSecurity httpSecurity) throws Exception {}
其中,AuthenticationManagerBuilder
用于配置全局认证相关的信息,就是UserDetailsService和AuthenticationProvider
WebSecurity
用于全局请求忽略规则配置,比如一些静态文件,注册登录页面的放行。
HttpSecurity
用于具体的权限控制规则配置,我们这里只需要重写这个方法就可以了。
修改配置类:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
//super.configure(http);
http.formLogin()
.loginPage("/login") //登录页面
.successForwardUrl("/index") //登录成功后的页面
.failureForwardUrl("/failure") //登录失败后的页面
.and()
// 设置URL的授权
.authorizeRequests()
// 这里需要将登录页面放行
.antMatchers("/login")
.permitAll()
//除了上面,其他所有请求必须被认证
.anyRequest()
.authenticated()
.and()
// 关闭csrf
.csrf().disable();
}
@Bean
public PasswordEncoder getPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
HttpSecurity
还有其他很多的方法,这里列举一些常用的:
方法 | 说明 |
---|---|
formLogin() | 开启表单的身份验证 |
loginPage() | 指定登录页面 |
successForwardUrl() | 指定登录成功之后跳转的页面 |
failureForwardUrl() | 指定登录失败之后跳转的页面 |
authorizeRequests() | 开启使用HttpServletRequest请求的访问限制 |
oauth2Login() | 开启oauth2验证 |
rememberMe() | 开启记住我 的验证(使用cookie) |
addFilter() | 添加自定义过滤器 |
csrf() | 开启csrf支持 |
接下来编写controller:
@Controller
public class UserController {
@RequestMapping("/login")
public String login(){
return "login";
}
@RequestMapping("/index")
public String index(ModelMap modelMap){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User principal = (User) authentication.getPrincipal();
modelMap.put("user",principal);
return "index";
}
@RequestMapping("/failure")
public String failure(){
return "failure";
}
}
测试
运行项目,访问http://localhost:8080,会直接跳转到登录页面,输入账号密码,跳转到登录成功的页面。
其他配置
如果需要在登录成功或者失败之后,做一些其他事情,那么上面的代码就满足不了这个需求了,需要自定义登录成功/失败逻辑,我们可以在配置文件中修改一下:
.successHandler((httpServletRequest, httpServletResponse, authentication) -> httpServletResponse.sendRedirect("/index"))
.failureHandler((httpServletRequest, httpServletResponse, authentication) -> httpServletResponse.sendRedirect("/failure"))
总结
认证流程
上面的流程是简化过后的版本,下一篇文章会详细介绍整个认证过程。
参考:
转载自:https://juejin.cn/post/6934503341380599822