• 1

  • 1

  • 收藏

Spring Security系列之一 简单介绍和实战

2星期前

Spring Security系列之一 简单介绍和实战

鉴于市面上的spring security教程参差不齐,要么一来就直接分析源码,要么就只是贴出一些代码说就这么配置就行了,以至于具体功能实现之后对security还是一知半解,本人也深受其害,所以决心搞一个spring security的系列,循序渐进,由浅入深。

章节

Spring Security系列之一 简单介绍和实战

Spring Security系列之二 认证流程分析

Spring Security系列之三 自定义短信登录认证

Spring Security系列之四 前后端分离项目用jwt做认证

Spring Security系列之五 前后端分离项目用户授权

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。

上面的项目就是一个最简单的实现了,当然,它非常不完善,存在几个问题:

  1. 当什么也没有配置的时候,账号和密码是由Spring Security定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。
  2. 登录界面是内置的,如果说登录过程中还有什么要验证的,登录成功之后跳转哪个页面,这些肯定是需要自定义的。

针对第一个问题,我们需要自定义控制认证逻辑,只需要实现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"))
复制代码

总结

认证流程

上面的流程是简化过后的版本,下一篇文章会详细介绍整个认证过程。

参考:

Spring Security-安全管理框架 - 知乎 (zhihu.com)

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

java

1

相关文章推荐

未登录头像

暂无评论