Fms-Java版开发实录:03 从数据库中读取用户进行验证

Fms-Java版开发实录:03 从数据库中读取用户进行验证

利用Spring Security提供的接口编写用户认证

用户登录验证功能是最基础的功能了,这里顺便就要复习一下Spring Security的登录验证功能。

我打算使用Spring Security提供的UserDetailsServiceUserDetails接口来完成从数据库中读取用户并且进行验证的功能。为此有如下几个步骤:

  1. 创建用户表,Role表和二者的多对多关系表,使用Hibernate来自行创建表
  2. 编写UserDetailsServiceUserDetails实现类
  3. 编写自定义登录模板
  4. 配置Spring Security来进行登录验证

为了达成这个目的,还得复习一下HibernateThymeleaf的内容。Hibernate在旧博客里仔细学习过Hibernate,不过依然有很多地方需要仔细再看看,Thymeleaf没有系统的学习过,必须开一个支线专门学一下。

创建UserRole类和多对多关系

cc.conyli.fms下创建entity包,创建一个User类,我这里选择直接让其实现UserDetails接口。
先来逐步编写其中的域,有一个id,一个enabled,然后是usernamepassword。用户名和密码域的名称设置为usernamepassword,和Spring Security内部的验证功能的键名一致,比较方便。如果域改成其他名称,需要在Spring Security中设置。

import org.hibernate.annotations.ColumnDefault;
import org.hibernate.validator.constraints.Length;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import javax.validation.constraints.NotBlank;
import java.util.Collection;

@Entity
@Table(name = "users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "id")
    private Integer id;

    @Column(name= "enabled")
    @ColumnDefault("false")
    private boolean enabled;

    @NotEmpty(message = "用户名不能为空")
    @Length(max = 150, message = "用户名长度最多为150个字符")
    @Column(name = "username", nullable = false, unique = true, length = 150)
    private String username;

    @NotEmpty(message = "密码不能为空")
    @Column(name = "password", nullable = false, length = 128)
    @Length(max = 128, message = "密码最长为128位")
    private String password;
}

然后是Role表,UserRole是多对多的关系,方便以后进行添加权限来方便快捷的控制。

import org.hibernate.validator.constraints.Length;

import javax.persistence.*;
import javax.validation.constraints.NotBlank;

@Entity
@Table(name = "role")
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "id")
    private Integer id;

    @NotEmpty(message = "权限名不能为空")
    @Length(max = 30, message = "权限最长30个字符")
    @Column(name = "role", nullable = false, unique = true, length = 30)
    private String role;

    public Role() {
    }
    
    public String getRole() {
        return role;
    }

    public Role(String role) {
        this.role = role;
    }

    @Override
    public String toString() {
        return "Role{" +
                "id=" + id +
                ", role='" + role + '\'' +
                '}';
    }
}

然后是进行多对多映射,这里由于会从User去查询Role,所以将多对多设置在User类中:

public class User implements UserDetails {
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
    private List<Role> roles;
}

这里因为要立刻把用户对应的权限查出来,所以就采用了EAGER,不过本来也不能跨Session使用。
然后来编写UserDetails接口中的方法:

public class User implements UserDetails {

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        roles.forEach(role -> authorities.add(new SimpleGrantedAuthority(role.getRole())));
        return authorities;
    }

    @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 this.enabled;
    }

}

这样就编写好了两个基础的UserRole类,其他的GetterSetter方法都省略,并且做了多对多映射,同时User类还是UserDetails的实现类。还要注意的是,对于UserRole还要编写equals方法,判断相等的条件是id相同。

UserDetailsServiceUserDetails实现类

User类同时作为UserDetails的实现类,不过UserDetailsService就要自行编写了,这里涉及到需要从数据库中取到用户,然后返回,先看一下UserDetailsService接口:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

就一个方法,即通过用户名来获取用户,同时返回UserDetails类型的对象。创建service包,然后创建一个AuthUserService来实现这个接口:

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 javax.transaction.Transactional;

@Service
public class AuthUserService implements UserDetailsService {

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return null;
    }
}

然后就是需要从数据库中读出User了,这里可以简单用JPA来实现,创建dao包,然后在其中创建一个UserDao接口,继承CrudRepository

package cc.conyli.fms.dao;

import cc.conyli.fms.entity.User;
import org.springframework.data.repository.CrudRepository;

public interface UserDao extends CrudRepository<User, Integer> {
    User findByUsername(String username);
}

由于JPA的便利性,只需要定义接口方法为findByUsername即可,JPA会自动解析我们想要的查询方式,这里也可以手工编写,待我之后为了实现业务逻辑的时候仔细研读即可。

之后把UserDao注入到AuthUserService里:

@Service
public class AuthUserService implements UserDetailsService {

    private final UserDao userDao;
    
    @Autowired
    public AuthUserService(UserDao userDao) {
        this.userDao = userDao;
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return userDao.findByUsername(s);
    }
}

这样就完成了UserDetailsServiceUserDetails实现类

配置Spring Security

为了检测目前为止是否能够生效,可以先来配置一下Spring Security,看看我们编写的方法是否能够生效:
cc.conyli.fms下创建config包,再创建SecurityConfig类,然后用这个类来配置Spring Security:

package cc.conyli.fms.config;

import org.springframework.beans.factory.annotation.Autowired;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //    注入userDetailsService
    private final UserDetailsService userDetailsService;

    @Autowired
    public SecurityConfig(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    //    创建BCrypt密码编码器的Bean
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    //    配置用户认证使用自行编写的UserDetailsService实现类=AuthUserService
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
    
    //    配置所有路径都需要验证,使用Spring Security提供的表单
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
        .formLogin().permitAll();
    }
}

这时候就可以先行启动项目,然后访问任意的路径,会弹出Spring Security自带的登录页面,之后输入用户名和密码就可以进行登录了。如果用户的enabled属性是false,会有提示无法登录。

如果输入了不存在的用户名,会发现提示为:

UserDetailsService returned null, which is an interface contract violation

这个原因是因为在AuthUserServiceloadUserByUsername中没有抛出异常,所以需要修改一下这个方法:

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        User user = userDao.findByUsername(s);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        } else {
            return user;
        }
    }

这样提示就会变成Bad credentials,表示用户名或者密码错误。看上去更加合理一些。

接下来就是要编写自定义的登录界面,涉及到Thymeleaf的使用,以及自行编写控制器。

LICENSED UNDER CC BY-NC-SA 4.0
Comment