SpringSecurity学习总结1-入门


1. SpringSecurity入门

1.1 创建入门项目

  我们先新建一个maven项目,我们使用SpringBoott方式,引入以下必要的依赖。

<parent>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-parentartifactId>
    <version>2.1.4.RELEASEversion>
parent>
   
<dependencies>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-securityartifactId>
    dependency>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-testartifactId>
        <scope>testscope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintagegroupId>
                <artifactId>junit-vintage-engineartifactId>
            exclusion>
        exclusions>
    dependency>
dependencies>

  编写一个SpringBoot项目的启动类

@SpringBootApplication
public class SpringSecurityApplication {

    public static void main(String[] args) {
        try {
            SpringApplication.run(SpringSecurityApplication.class, args);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

  在resources目录下,新建一个文件夹static,在static下创建一个 index.html文件

DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>首页title>
head>
<body>
    登录成功!!
body>
html>

1.2 测试入门项目

  我们运行启动类 SpringSecurityApplication,当项目启动之后,启动日志里会有这样一句日志(说明SpringSecurity已经生效,默认登录用户名是user, 默认密码每次启动都不一样)

Using generated security password: afa0c4a7-9108-42b7-9d40-b663920cb72d

   当项目启动后,输入请求地址:http://localhost:8080/   浏览器自动跳转到地址  http://localhost:8080/login 会看到一个登录页面。

  这个登录页面是SpringSecurity自带的,输入日志里打印的username和password之后,登录成功,浏览器会挑战到我们自己写的index.html页面。

2. Spring Security的基础方法

2.1 UserDetailsService接口

package org.springframework.security.core.userdetails;

public interface UserDetailsService {
   
    /**
     * Locates the user based on the username. In the actual implementation, the search
     * may possibly be case sensitive, or case insensitive depending on how the
     * implementation instance is configured. In this case, the UserDetails
     * object that comes back may have a username that is of a different case than what
     * was actually requested..
     *
     * @param username the username identifying the user whose data is required.
     *
     * @return a fully populated user record (never null)
     *
     * @throws UsernameNotFoundException if the user could not be found or the user has no
     * GrantedAuthority
     */
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

  UserDetailsService接口是Spring Security的主要类,后面我们需要继承这个接口,来实现自己的业务逻辑。

  其中loadUserByUsername(String username)是用来实现登录逻辑的,参数username就是登录用户名,也就是刚才我们填写的user,如果用户名不存在会报UsernameNotFoundException异常,返回值是UserDetails对象。

2.1 UserDetails接口

package org.springframework.security.core.userdetails;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

import java.io.Serializable;
import java.util.Collection;

public interface UserDetails extends Serializable {
    
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

  UserDetails也是一个接口类,以后我们也可以让自己的用户类实现UserDetails的方法,来完成自己的业务逻辑。

  UserDetails继承了Serializable,说明可以被序列化。

  Collection<? extends GrantedAuthority> getAuthorities(); 此方法用于获取用户权限的,并且结果不能为null

  String getPassword();  获取用户的密码。

  String getUsername();  获取用户名。

  boolean isAccountNonExpired(); 判断账号是否未过期,过期的账号无法进行认证。

  boolean isAccountNonLocked();  判断账号是否未被锁定。被锁定的用户无法进行认证。

  boolean isCredentialsNonExpired(); 判断用户的凭证(密码)是否未过期,过期的凭据无法进行身份验证。

  boolean isEnabled(); 账号是否被启动,未启动的用户无法进行认证。

2.3 User类

  UserDetails是一个接口,所以不能直接使用,SpringSecurity提供了一个实现类User

package org.springframework.security.core.userdetails;

public class User implements UserDetails, CredentialsContainer {

    private String password;
    private final String username;
    private final Set 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) {

        if (((username == null) || "".equals(username)) || (password == null)) {
            throw new IllegalArgumentException(
                    "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));
    }
}

  User继承了UserDetails,提供了UserDetails的7条属性和2个构造方法。User(String username, String password, Collection<? extends GrantedAuthority> authorities) 这三个参数的构造方法实际上是调用了下面7个参数的构造方法,传的参数就是username、password和authorities。

  当UserDetailsService的loadUserByUsername方法显示登录认证之后,会在数据库或内存中获取到username、password、authorities赋值给UserDetails的实现类User,并返回,实现了完整的登录逻辑。

2.4 PsswordEncoder 密码加密

  接下来我们认识一下密码验证的核心接口 PasswordEncoder,

  密码解析器,接口,

package org.springframework.security.crypto.password;

public interface PasswordEncoder {

    String encode(CharSequence rawPassword);

    boolean matches(CharSequence rawPassword, String encodedPassword);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

  String encode(CharSequence rawPassword); 密码加密方法,参数rawPassword可以理解为客户端的明文密码,SpringSecurity推荐使用SHA-1或者Hash算法加密,Hash算法推荐使用8位字符或随机salt。

  boolean matches(CharSequence rawPassword, String encodedPassword); 密码匹配方法,rawPassword是明文密码,encodedPassword是加密后的密码,匹配这两个密码是否一致。

  default boolean upgradeEncoding(String encodedPassword) 二次加密方法,对已加密的密码,再次加密。默认返回false是不需要二次加密。

  PasswordEncoder是接口,它也有很多实现类,官方推荐使用的实现类是 BCryptPasswordEncoder

public class BCryptPasswordEncoder implements PasswordEncoder {
    private Pattern BCRYPT_PATTERN = Pattern.compile("\\A\\$2a?\\$\\d\\d\\$[./0-9A-Za-z]{53}");
    private final Log logger = LogFactory.getLog(getClass());

    private final int strength;

    private final SecureRandom random;

    public BCryptPasswordEncoder() {
        this(-1);
    }

    /**
     * @param strength the log rounds to use, between 4 and 31
     */
    public BCryptPasswordEncoder(int strength) {
        this(strength, null);
    }

    /**
     * @param strength the log rounds to use, between 4 and 31
     * @param random the secure random instance to use
     *
     */
    public BCryptPasswordEncoder(int strength, SecureRandom random) {
        if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) {
            throw new IllegalArgumentException("Bad strength");
        }
        this.strength = strength;
        this.random = random;
    }

    public String encode(CharSequence rawPassword) {
        String salt;
        if (strength > 0) {
            if (random != null) {
                salt = BCrypt.gensalt(strength, random);
            }
            else {
                salt = BCrypt.gensalt(strength);
            }
        }
        else {
            salt = BCrypt.gensalt();
        }
        return BCrypt.hashpw(rawPassword.toString(), salt);
    }

    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (encodedPassword == null || encodedPassword.length() == 0) {
            logger.warn("Empty encoded password");
            return false;
        }

        if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
            logger.warn("Encoded password does not look like BCrypt");
            return false;
        }

        return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
    }
}

  BCryptPasswordEncoder是一个强哈希加密方法。

  private final int strength;  此参数决定了密码强度,默认是10。如果是10以上的数字,密码会更加安全,但是会在加密过程消耗更多的性能。

  public String encode(CharSequence rawPassword) 对明文密码加密,加密时使用了随机salt(随机字符串),保证每次加密结果不一样。(注:如果没有使用随机salt,相同字符串加密后的结果是一样,就很容易猜到密码,很不安全)

  我们可以写个测试用例试一下,多运行几次会发现每次加密的结果是不一样的,这就是随机salt起作用了。

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringSecurityApplication.class)
public class SpringSecurityTest {

    @Test
    public void checkPassword(){
        PasswordEncoder pw = new BCryptPasswordEncoder();
        // 加密
        String encode = pw.encode("123");
        System.out.println("==== 加密后的密码:" + encode);
        // 比较密码
        boolean matches = pw.matches("123", encode);
        System.out.println("==== 比较密码:" + matches);
    }
}

3. 登录功能实现

3.1 配置密码解析器

  当我们要实现自定义登录时,Spring容器内必须已经存在密码解析器,所以我们要提前把PasswordEncoder写到配置类里面去,让Spring来管理。

@Configuration
public class SecurityConfig {

    /**
     * 密码解析器
     */
    @Bean
    public PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

}

3.2 实现登录验证

  创建一个类,实现UserDetailsService接口。正常情况下用户密码都要从数据库查询的,我这里为了测试方便,直接写死的。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 根据用户名去数据库查询,如果不存在就抛UsernameNotFoundException异常
        if(!"admin".equals(username)){
            throw new UsernameNotFoundException("用户名不存在");
        }
        // 2. 比较密码(注册时已经加密过,如果匹配成功返回UserDetails)
        String password = passwordEncoder.encode("123");
        // 正常逻辑是需要使用 passwordEncoder.matches() 方法来验证密码的是否正确的。

        return new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal"));
    }
}

  配置完毕后,我们再次启动应用程序,发现日志里不打印默认用户名密码的,在登录页面只能输入 username=admin, password=123 才可以登录。