原创

SpringBoot2.x整合Security5(完美解决 There is no PasswordEncoder mapped for the id "null")

问题描述

以前做过一次SpringBoot整合Security的项目,但最近重新尝试SpringBoot整合Security的项目时却碰到了问题

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

原来SpringBoot升级到了2.0之后的版本,Security也由原来的版本4升级到了5,所以花了点时间研究,以此记录

工具

  1. IDE:IDEA
  2. 项目依赖管理:Maven
  3. 持久层框架:JPA

SpringBoot 2.0 整合 Security5

1. 项目主要依赖

security

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

SpringBoot

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

同时引入SpringData JPA持久层框架和 Lombok工具简化GET、SET方法

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

2. SpringBoot配置

这里我使用更加简洁的yml文件来配置
properties.yml:

server:
  port: 8888
spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/three_point
    driver-class-name: com.mysql.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false

ddl-auto: update :使用了JPA的自动建表,根据实体类自动生成数据表(需要先创建数据库)

3. 实体类

用户类

package com.pan.entity;

import lombok.Data;

import javax.persistence.*;
import java.util.List;

@Entity
@Table
@Data
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String username;
    private String password;
    @ManyToMany(cascade = {CascadeType.REFRESH},fetch = FetchType.EAGER)
    private List<Role> roles;

    public User() {
    }

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }
}

角色类

package com.pan.entity;

import lombok.Data;

import javax.persistence.*;

@Entity
@Table
@Data
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String name;
}

4. DAO类

JPA操作接口

package com.pan.repository;

import com.pan.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

    User findByUsername(String username);
}

UserDetailsService 实现类,用于Security查询角色进行认证

package com.pan.service.impl;

import com.pan.entity.Role;
import com.pan.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        com.pan.entity.User user = userRepository.findByUsername(s);
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (Role role : user.getRoles()) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        }
        return new User(user.getUsername(), user.getPassword(), authorities);
    }
}

这里我使用的是User不继承UserDetails这个接口,而将添加认证用户的操作放在了上面这个类中,还有另外一种实现方式


:这里为另外一个项目,属性会有所不同,仅供演示,代码如下:
用户类

@Entity
@Table(name = "user")
@Getter
@Setter
public class Member implements UserDetails {

    @Id
    @GeneratedValue
    private Integer id;

    private String username;

    private String password;

    @ManyToMany(cascade = {CascadeType.REFRESH},fetch = FetchType.EAGER)
    private List<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> auths = new ArrayList<>();
        List<Role> roles = this.getRoles();
        for (Role role : roles) {
            auths.add(new SimpleGrantedAuthority(role.getMark()));
        }
        return auths;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.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;
    }
}

UserDetailsService实现类

public class MemberService implements UserDetailsService {

    @Autowired
    MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String name) {

        Member member = memberRepository.findByUsername(name);
        if (member == null) {
            throw new UsernameNotFoundException("用户名不存在");
        }

        return member;
    }
}

两种写法都可,但个人认为这种写法较为繁琐


继续原来的内容

5. Controller控制器和HTML视图

控制器

两个简单的请求,登录和注册

package com.pan.controller;

import com.pan.entity.User;
import com.pan.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;

@Controller
public class IndexController {

    @Autowired
    private UserRepository userRepository;

    @ResponseBody
    @RequestMapping("/personal_center")
    public void login(HttpServletRequest request) {
        System.out.println("登录成功");
    }

    @ResponseBody
    @PostMapping("/registry")
    public void registry(User user) {
        userRepository.save(new User(user.getUsername(), user.getPassword()));
    }
}

视图

登录页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/login" method="post">
    姓名:<input type="text" name="username">
    密码:<input type="password" name="password">
    <button type="submit">登录</button>
</form>
</body>
</html>

注册页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/registry" method="post">
    姓名:<input type="text" name="username">
    密码:<input type="password" name="password">
    <button type="submit">注册</button>
</form>
</body>
</html>

界面简陋请见谅

6. Security配置类

视图类配置

package com.pan.security;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/sign_in").setViewName("login");
        registry.addViewController("/sign_up").setViewName("registry");
    }
}

响应指定请求,返回HTML视图,简化Controller的书写,等同于以下内容:

/**
 * WebMvcConfig类等效内容
 */
@RequestMapping("/sign_in")
public String sign_in() {
    return "login";
}

@RequestMapping("/sign_up")
public String sign_up() {
    return "registry";
}

Security核心配置类

在SpringBoot 2.0 版本以前,是这样配置的
旧配置

package com.pan.security;

import com.pan.service.impl.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    UserDetailsService detailsService() {
        return new UserDetailsServiceImpl();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(detailsService());
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/config/**", "/css/**", "/fonts/**", "/img/**", "/js/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.headers()
                .and().authorizeRequests()
                .antMatchers("/registry").permitAll()
                .anyRequest().authenticated()
                .and().formLogin().loginPage("/sign_in") 
                .loginProcessingUrl("/login").defaultSuccessUrl("/personal_center",true)
                .failureUrl("/sign_in?error").permitAll()
                .and().sessionManagement().invalidSessionUrl("/sign_in")
                .and().rememberMe().tokenValiditySeconds(1209600)
                .and().logout().logoutSuccessUrl("/sign_in").permitAll()
                .and().csrf().disable();
    }
}

具体的配置请自行网上搜索

在数据库中预先存储用户名和密码,然后登录验证,完全没有问题
但现在,却出问题了,后台报错

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

这是什么鬼?以前可没有这种问题啊

查阅资料得知,SpringBoot2.0抛弃了原来的NoOpPasswordEncoder,要求用户保存的密码必须要使用加密算法后存储,在登录验证的时候Security会将获得的密码在进行编码后再和数据库中加密后的密码进行对比,文献如下:

SpringBoot官方文档

在官方文档中,给出了解决方案,我们可以通过在配置类中添加如下配置来回到原来的写法

@Bean
public static NoOpPasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

这样能解决办法,但NoOpPasswordEncoder 已经被官方废弃了,既然废弃它肯定是有原因的,而且这种勉强的做法也不符合我们程序员精益求精的风格

正确做法如下:

写法一:

第一步: 在WebSecurityConfig中定义一个新的bean对象
@Bean
public PasswordEncoder passwordEncoder() {
    // return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    return new BCryptPasswordEncoder();
}

这里使用的是BCryptPasswordEncoder编码方式也可选择其他,如下:

String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);

PasswordEncoderFactories.createDelegatingPasswordEncoder()方法默认返回值为BCryptPasswordEncoder(),两个return等价

第二步:修改WebSecurityConfig的一个重写方法

SpringBoot2.0以前旧配置为:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(detailsService());
}

修改为新配置:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(detailsService()).passwordEncoder(passwordEncoder());
}

passwordEncoder(passwordEncoder())里面的passwordEncoder()为我们定义的bean
这样在登录的时候就会使用我们选择编码方式进行验证

也可以不写上述configure(AuthenticationManagerBuilder auth)方法,但需要在Security配置类(WebSecurityConfig)中添加以下内容:

@Bean
public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(detailsService());
    authenticationProvider.setPasswordEncoder(passwordEncoder());
    return authenticationProvider;
}

将我们定义的PasswordEncoder的Bean和自定义的UserDetailsService注入到DaoAuthenticationProvider,上面所写的修改方法在Security内部也是创建DaoAuthenticationProvider,两者等价

由于使用了编码验证,所以我们需要一组编码后的密码,否则会有如下警告:

o.s.s.c.bcrypt.BCryptPasswordEncoder     : Encoded password does not look like BCrypt
第三步:修改IndexController
  1. 自动注入一个PasswordEncoder

     @Autowired
     private PasswordEncoder passwordEncoder;
    
  2. 修改registry()方法

     @ResponseBody
     @PostMapping("/registry")
     public void registry(User user) {
         userRepository.save(new User(user.getUsername(), passwordEncoder.encode(user.getPassword())));
     }
    

    passwordEncoder.encode(user.getPassword())在密码保存时进行密码编码加密
    也可以将加密封装成一个工具类,方便调用。切记封装工具类要用构造方法生成PasswordEncoder 对象,否则会报空指针异常

工具类封装

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

public class PasswordEncoderUtil {

    private static PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); //使用构造方法生成对象

    /**
     * 对密码使用BCryptPasswordEncoder加密方式进行加密
     */
    public static String passwordEncoder(String password) {
        return passwordEncoder.encode(password);
    }
}

加密后的密码:
这里写图片描述
这样就可以完美解决问题了,密码的安全性也有了保障

写法二:

第一步:WebSecurityConfig配置

不定义PasswordEncoder的bean
修改userDetailsServiceconfigure()重写方法

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(detailsService()).passwordEncoder(new BCryptPasswordEncoder());
}

若不写该方法,则添加:

@Bean
public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(detailsService());
    authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
    return authenticationProvider;
}
第二步:修改 IndexController

不用自动注入,new一个编码对象,然后使用

private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

总结

到此,整合就初步完成了,整合问题也得到了完美解决,希望我的文章能对你有所帮助

源码

源码放在GitHub上了。若有帮助,希望GitHub能给我一个Star,诚挚的感谢!
SecurityDemo

正文到此结束