一.前言

1.介绍
  • 上文主要介绍了后端使用Spring Security对API进行保护
  • 本文主要介绍Spring Security使用数据库存储角色和权限,并在此情况下进行登录操作,同时介绍了记住我操作
2.项目例子
  • 此文章用到的例子在spring-boot项目中,传送门
  • 此篇文章用到项目模块:spring-boot-security-login
  • 还有更多:spring-cloud项目
3.概述
  • 使用数据库存储用户,角色和权限,代码中使用jpa进行数据库访问
  • 自定义UserDetailsService用于登陆时获取用户信息
  • 添加rememberMe记住我的功能
  • sql文件在项目模块中login.sql
  • 基于上篇后端使用Spring Security对API进行保护添加登陆的新功能

二.Spring Security用户,角色和权限

1. 数据库表设计
  • 用户,角色,权限,之间多对多关系
  • 用户实体类,包含角色
@Entity
@Table(name = "user_account")
public class User {

    @Id
    @Column(unique = true, nullable = false)
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String firstName;

    private String lastName;

    private String email;

    @Column(length = 60)
    private String password;

    private boolean enabled;
    private boolean isUsing2FA;

    private String secret;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
    private Collection<Role> roles;
  • 角色实体类,包含权限
@Entity
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @ManyToMany(mappedBy = "roles")
    private Collection<User> users;

    @ManyToMany
    @JoinTable(name = "roles_privileges", joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id"))
    private Collection<Privilege> privileges;

    private String name;
  • 权限实体类
@Entity
public class Privilege {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @ManyToMany(mappedBy = "privileges")
    private Collection<Role> roles;
2. 自定义获取登陆时用户数据
  • 实现UserDetailsService接口
  • 重写loadUserByUsername方法,返回User给security进行验证
  • 使用userRepository从数据库中获取用户以及权限
  • User构造方法,依次是账号,密码,账号是否过期,证书是否过期,是否锁定账号,权限集合
@Service("userDetailsService")
@Transactional
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    public MyUserDetailsService() {
        super();
    }

    // API
    @Override
    public UserDetails loadUserByUsername(final String email) throws UsernameNotFoundException {
        try {
            final User user = userRepository.findByEmail(email);
            if (user == null) {
                throw new UsernameNotFoundException("No user found with username: " + email);
            }

            return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, getAuthorities(user.getRoles()));
        } catch (final Exception e) {
            throw new RuntimeException(e);
        }
    }

    // UTIL
    private final Collection<? extends GrantedAuthority> getAuthorities(final Collection<Role> roles) {
        return getGrantedAuthorities(getPrivileges(roles));
    }

    private final List<String> getPrivileges(final Collection<Role> roles) {
        final List<String> privileges = new ArrayList<>();
        final List<Privilege> collection = new ArrayList<>();
        for (final Role role : roles) {
            collection.addAll(role.getPrivileges());
        }
        for (final Privilege item : collection) {
            privileges.add(item.getName());
        }

        return privileges;
    }

    private final List<GrantedAuthority> getGrantedAuthorities(final List<String> privileges) {
        final List<GrantedAuthority> authorities = new ArrayList<>();
        for (final String privilege : privileges) {
            authorities.add(new SimpleGrantedAuthority(privilege));
        }
        return authorities;
    }

}
3. 将自定义用户源放入到spring security配置中
  • 将MyUserDetailsService 放入到spring security配置中
@Autowired
    private MyUserDetailsService userDetailsService;
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }
4. 使用PreAuthorize标识访问接口需要使用的权限
  • 开启PreAuthorize支持,将prePostEnabled设置为true
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
public class SecurityJavaConfig extends WebSecurityConfigurerAdapter {
  • controller中DeleteMapping需要DELETE_USER权限,而GetMapping需要GET_USER权限
@RestController
	@RequestMapping("/api/user")
	public class BusinessController {
	
	    @DeleteMapping("/{id}")
	    @PreAuthorize("hasAuthority('DELETE_USER')")
	    public String deleteUser(@PathVariable Long id){
	        return "delete user success by user id :"+id;
	    }
	
	    @GetMapping("/{id}")
	    @PreAuthorize("hasAuthority('GET_USER')")
	    public User getUser(@PathVariable Long id){
	        User user = new User();
	        user.setId(id);
	        return user;
	    }
	}
5. 验证
  • 没有登录,访问接口,返回401 Unauthorized
@Test
public void notLogin() {
        ResponseEntity<String> entity = this.restTemplate.exchange("http://localhost:8081/api/user/1", HttpMethod.DELETE,
                new HttpEntity<Void>(loginHeaders), String.class);
        assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
  • 账号test2@test.com账号没有调用删除用户接口,返回403 Forbidden
@Test
public void noHasDeleteUserAuthority() {
    MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
    form.set("username", "test2@test.com");
    form.set("password", "123456");
    login(form);
    ResponseEntity<String> entity = this.restTemplate.exchange("http://localhost:8081/api/user/1", HttpMethod.DELETE,
            new HttpEntity<Void>(loginHeaders), String.class);
    assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}

三.记住我

1. 在spring security中设置记住我
  • 在configure(HttpSecurity http)中添加rememberMe
  • 添加过期时间为24小时
.and().rememberMe().tokenValiditySeconds(60*60*24);
2. 将servlet session有效活动时间修改为1分钟
  • 默认servlet session有效活动时间为30分钟
  • 在application.yml中设置
server:
  port: 8081
  tomcat:
    uri-encoding: UTF-8
  servlet:
    session:
      timeout: 1m
3. 介绍
  • 当登陆的时候,使用remember-me:true,此时返回值会生成remember-me的cookie值,存储了账号的md5加密和过期时间,如下
remember-me=dGVzdCU0MHRlc3QuY29tOjE1NjY4MzE1NjQzNjQ6YjNiODE4YzhhZTgwMDMwNzY4NDE2YTE1ZDU5YmZmOTg; Max-Age=30000; Expires=Mon, 26-Aug-2019 14:59:24 GMT; Path=/; HttpOnly
  • 同时登陆,返回登陆cookie凭证如下
JSESSIONID=1DD1D2BDFDA29944732B394F26F73D7E; Path=/; HttpOnly
  • 因为我们设置了session的过期时间为1分钟后,在登陆一分钟后JSESSIONID访问业务接口会失败
  • 而使用remember-me的cookie还可以访问业务接口
4. 验证
  • 我们将返回header中的cookie存储到文件中
  • 当一分钟后使用JSESSIONID访问业务接口失败
  • 使用remember-me的cookie访问业务接口成功
@Test
   public void deleteOneMinuteLaterByJsessionId() throws InterruptedException, IOException {
       HttpHeaders loginHeaders = getHttpHeaders(1);
       //sleep one minute until session expired
       Thread.sleep(60000L);
       ResponseEntity<String> entity = this.restTemplate.exchange("http://localhost:8081/api/user/1", HttpMethod.DELETE,
               new HttpEntity<Void>(loginHeaders), String.class);
       assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
   }

   @Test
   public void deleteByRememberMeCookie() throws IOException {
       HttpHeaders loginHeaders = getHttpHeaders(0);
       ResponseEntity<String> entity = this.restTemplate.exchange("http://localhost:8081/api/user/1", HttpMethod.DELETE,
               new HttpEntity<Void>(loginHeaders), String.class);
       assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
   }

四.总结

  • 本文主要介绍Spring Security使用数据库存储角色和权限,并在此情况下进行登录操作,同时介绍了记住我操作

上一篇:Spring Security 对Rest风格API的保护