一、这节开始,我们通过mysql存储用户信息(springboot)
1、引入jpa和mysql依赖,同时配置mysql
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
配置:
spring.datasource.url=jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT
spring.datasource.username=root
spring.datasource.password=xxx
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
2、设计表
涉及到三张表:用户表,角色表,用户角色关联表,表设计如下:
CREATE TABLE IF NOT EXISTS `user`
(
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`username` varchar(128) NOT NULL,
`password` varchar(128) NOT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY (`username`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE user CHANGE username user_name varchar(128) NOT NULL;
CREATE TABLE IF NOT EXISTS `role`
(
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`role_name` varchar(128) NOT NULL,
PRIMARY KEY (`id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `user_role`
(
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`user_id` bigint(11) NOT NULL ,
`role_id` bigint(11) NOT NULL
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
插入测试数据(密码这里都是使用了BCryptPasswordEncoder 需在SecurityConfig中加入配置):
//密码为123456
insert into user (`user_name`,`password`,`create_time`) values('testuser',' $2a$10$0HHE8ldG4hnC.sVGUFNdB.sVFf.kSvKZU21y7mPyqo3hC2xducvL2','2019-3-1'),('testadmin',' $2a$10$0HHE8ldG4hnC.sVGUFNdB.sVFf.kSvKZU21y7mPyqo3hC2xducvL2','2019-3-1');
insert into role(`role_name`) values('ADMIN'),('USER');
insert into user_role(`user_id`,`role_id`) values('1','2'),('2','1'),('2','2');
3、三个表对应实体类(注意: 可以通过 @Entity @Column 等注解自动生成上面的表)
@Data
@Entity
public class User implements Serializable {
@Id
private Long id;
private String userName;
private String password;
@JsonFormat(timezone="GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}
@Data
@Entity
public class Role implements Serializable {
@Id
private Long id;
private String roleName;
}
@Data
@Entity
public class UserRole {
@Id
private Long id;
private Long userId;
private Long roleId;
}
实体类通过lombok注解省去get/set方法,这里不细讲,引入lombok依赖。
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.6</version>
</dependency>
4、User实体类对应的jpa接口(其他忽略)
public interface UserRepository extends JpaRepository<User,Long> {
User findOneByUserName(String userName);
}
5、spring security 通过调用 UserDetailsService 实现类的 loadUserByUsername 方法获取到UserDetails 实现类,从而进行用户的身份认证,因此我们需要自定义 UserDetailsService 接口对应的实现类完成相应获取用户逻辑,这是自定义用户功能的核心。
Authentication接口才是真正使用的安全验证对象(有未认证、已认证两种状态),UserDetails 是用户安全信息的源,security会把 UserDetails 与 Authentication 进行匹配,成功后将用户信息拷贝到 Authentication中,最后Authentication把身份信息与其他组件共享。 另外Authentication实现类都保存了一个GrantedAuthority列表,该列表表示用户所具有的权限。GrantedAuthority是通过AuthenticationManager设置到Authentication对象中的,然后AccessDecisionManager将从Authentication中获取用户所具有的GrantedAuthority来鉴定用户是否具有访问对应资源的权限。
@Component
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
//1.根据用户名查找用户
User user = userRepository.findOneByUserName(name);
if (Objects.isNull(user)){
//注意,这是要求的,找不到抛异常
throw new UsernameNotFoundException(name + "not found");
}
//2、表关联查询获取用户对应的角色信息,这里忽略,可以写mapper在查用户的时候一次性获取
List<Role> roles = xxxxx;
// 2. 将用户拥有的权限加到 grantedAuthorities(此处),注意我们在所有权限前面加了'ROLE_'字符串,这是因为下面 MySecurityConfig 类中对资源加权限 hasRole("ADMIN") 方法中会为加入的字符串前面统一加上"ROLE_",可以看源码
Collection<GrantedAuthority> grantedAuthorities = roles.stream().map(r -> {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority("ROLE_" + r.getRoleName());
return grantedAuthority;
}).collect(Collectors.toList());
//Collection<GrantedAuthority> grantedAuthorities = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
//3、按要求返回security对应的User,其实现了UserDetails
return new org.springframework.security.core.userdetails.User(name,
user.getPassword(), grantedAuthorities);
}
}
6、在 WebSecurityConfigurerAdapter 对应实现类 SecurityConfig 加入 MyUserDetailsService 配置,同时还要自定义 PasswordEncoder(BCryptPasswordEncoder) 实现对密码的加密支持
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 设置自定义的userDetailsService
auth.userDetailsService(myUserDetailsService)
//也可自定义实现 PasswordEncoder 接口
.passwordEncoder(passwordEncoder());
}
@Bean
//处理密码的问题,生成密码可以使用PasswordEncoder 的encode方法,主要处理加密/密码匹配等
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 在UsernamePasswordAuthenticationFilter 过滤器前加自定义过滤器
//validateCodeFilter 是我们自己实现的过滤器,该过滤器继承 OncePerRequestFilter ,保证一个请求该过滤器只被调用一次
//http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
http
.authorizeRequests()
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/filter", "/js/**", "/css/**");
}
}
7、至此,我们就实现了从数据库获取用户信息并整合到spring security的功能。
8、另外,可能部署到生成环境后,会出现希望 /user/** 对应的接口改成只给USER角色的用户访问的需求。 所以一般角色管理比较复杂的项目,一般还会有两个数据表,一个存放应用提供的接口表permission(主要含 id、url、资源描述信息如菜单等字段) 以及角色与访问资源对应的关联关系表role_permission(主要含 role_id、permission_id)。
其实RBAC的通用数据模型都是五张表:用户表、角色表、资源表、用户与角色关联表、角色与资源关联表,其中用户表与角色表n对n,角色表与资源表也是n对n关系。
(1)创建实现 FilterInvocationSecurityMetadataSource 的类来储存请求资源与权限的对应关系。
@Component
public class MyFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
//定义map存放访问资源与需要的角色Collection<ConfigAttribute>决策器 的关系
private static HashMap<String, Collection<ConfigAttribute>> map =null;
@Override
//当接收到http请求时, filterSecurityInterceptor会调用的方法。这个方法返回请求该url所需要的所有权限集合。
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
//初始化 访问资源(url)与角色的对应关系(即将角色与资源关联表role_permission的所有数据取出,加到权限设置)
if (map == null){
map = new HashMap<>();
List<RolePermisson> rps = xxx; //获取关系表的全部内容
//遍历全部关联信息,取出对应的 url 和 角色信息(某个资源可以被哪个角色访问)
rps.stream().forEach(rp -> {
String roleId = rp.getRoleId();
String permissionId = rp.getPermissionId();
String roleName = xxx; //通过roleId 找出roleName;
String url = xxx; //通过permissionId 找出访问url
ConfigAttribute configAttribute = new SecurityConfig(roleName);
//url是否加过别的角色
if (map.containsKey(url)){
map.get(url).add(configAttribute);
} else{
List<ConfigAttribute> configAttributes = new ArrayList<>();
configAttributes.add(configAttribute);
map.put(url, configAttributes);
}
});
}
//object 中包含用户请求的request 信息
HttpServletRequest request = ((FilterInvocation) o).getHttpRequest();
for (Iterator<String> it = map.keySet().iterator(); it.hasNext();) {
String url = it.next();
if (new AntPathRequestMatcher(url).matches(request)) {
return map.get(url);
}
}
return null;
}
@Override
//Spring容器启动时自动调用, 一般把所有请求与权限的对应关系也要在这个方法里初始化, 保存在一个属性变量里。
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
//该类是否能够为某资源提供ConfigAttributes。
public boolean supports(Class<?> aClass) {
return true;
}
}
(2)创建实现 AccessDecisionManager 的类来负责鉴定用户是否有访问对应资源(方法或URL)的权限,其由AbstractSecurityInterceptor调用的。(决策器)
@Component
class MyAccessDecisionManager implements AccessDecisionManager {
/**
* 通过参数来决定用户是否有访问对应资源的权限
* @param authentication 含当前用户信息,以及拥有的权限。权限来源于前面登录时UserDetailsService中设置的authorities。
* @param o FilterInvocation对象,可以得到request
* @param collection configAttributes是本次访问需要的权限
*/
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
if (Objects.isNull(collection) || collection.isEmpty()) {
return;
} else {
collection.stream().forEach(c -> {
String shouldRole = c.getAttribute();
authentication.getAuthorities().stream().forEach(a -> {
//有访问权限,直接return
if (shouldRole .trim().equals(((GrantedAuthority) a).getAuthority().trim())){
return;
}
});
});
throw new AccessDeniedException("当前访问没有权限");
}
}
@Override
//表示此AccessDecisionManager是否能够处理传递的ConfigAttribute呈现的授权请求
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
//本类是否能够为指定的资源提供访问控制决策
public boolean supports(Class<?> aClass) {
return true;
}
}
(3)创建继承 AbstractSecurityInterceptor 的类,AbstractSecurityInterceptor 是一个实现了对受保护资源的访问进行拦截的抽象类。这里主要就是为了使用我们之前自定义的 MyAccessDecisionManager 和 MyFilterInvocationSecurityMetadataSource。
@Component
public class MyFilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter{
@Autowired
private FilterInvocationSecurityMetadataSource securityMetadataSource;
@Autowired
public void setMyAccessDecisionManager(MyAccessDecisionManager myAccessDecisionManager) {
super.setAccessDecisionManager(myAccessDecisionManager);
}
@Override
public Class<?> getSecureObjectClass() {
return FilterInvocation.class;
}
@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
return securityMetadataSource;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
//执行下一个拦截器
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.afterInvocation(token, null);
}
}
}
AbstractSecurityInterceptor的机制可以分为几个步骤:
- 查找与当前请求关联的“配置属性(简单的理解就是权限)”
- 将 安全对象(方法调用或Web请求)、当前身份验证、配置属性 提交给决策器(AccessDecisionManager)
- (可选)更改调用所根据的身份验证
- 允许继续进行安全对象调用(假设授予了访问权)
- 在调用返回之后,如果配置了AfterInvocationManager。如果调用引发异常,则不会调用AfterInvocationManager。
AbstractSecurityInterceptor中的方法说明:
- beforeInvocation()方法实现了对访问受保护对象的权限校验,内部用到了AccessDecisionManager和AuthenticationManager;
- finallyInvocation()方法用于实现受保护对象请求完毕后的一些清理工作,主要是如果在beforeInvocation()中改变了SecurityContext,则在finallyInvocation()中需要将其恢复为原来的SecurityContext,该方法的调用应当包含在子类请求受保护资源时的finally语句块中。
- afterInvocation()方法实现了对返回结果的处理,在注入了AfterInvocationManager的情况下默认会调用其decide()方法。
9、默认情况下,其实 spring security 使用到了session,即身份认证信息是通过session和cookie实现的。