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

x33g5p2x  于2021-07-11 转载在 Java  
字(13.4k)|赞(0)|评价(0)|浏览(666)

鉴于市面上的spring security教程参差不齐,要么一来就直接分析源码,要么就只是贴出一些代码说就这么配置就行了,以至于具体功能实现之后对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)

相关文章