文章目录
GitHub:shpunishment/spring-security-rbac0-demo
1. RBAC0
RBAC0 定义了能构成 RBAC 权限控制系统的最小的集合,RBAC0 由四部分构成:
用户(User) 权限的使用主体
角色(Role) 包含许可的集合
会话(Session)绑定用户和角色关系映射的中间通道。而且用户必须通过会话才能给用户设置角色。
许可(Pemission) 对特定资源的特定的访问许可。
根据以上分析,需要用户表,角色表,菜单表,用户角色关联表以及角色菜单关联表
表 | 字段 |
---|---|
用户表 user | id,nickname,username,password,enable |
角色表 role | id,role_name |
菜单表 menu | id,menu_name,url,permission |
用户角色关联表 user_role | id,user_id,role_id |
角色菜单关联表 role_menu | id,role_id,menu_id |
关系
菜单有权限值,通过角色来分配菜单,再把角色分配给用户。
用户所属不同的角色,或修改所属角色拥有的菜单,从而实现对用户权限的控制。
实现
现在有page1~page6六个页面进行权限控制,/ 和 /home 无权限可访问。
配置Spring Security
@Configuration
@EnableWebSecurity
// 开启方法级别保护
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/js/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// / 和 /home 路径配置为不需要任何身份验证,其他所有路径必须经过验证
.antMatchers("/", "/home").permitAll()
// 其他请求都需要已认证
.anyRequest().authenticated()
.and()
// 使用表单登录
.formLogin()
// 自定义username 和password参数
.usernameParameter("login_username")
.passwordParameter("login_password")
// 自定义登录页地址
.loginPage("/loginPage")
// 验证表单的地址,由过滤器 UsernamePasswordAuthenticationFilter 拦截处理
.loginProcessingUrl("/login")
.permitAll()
.and()
// 默认为 /logout ,登出后默认跳转到 /login?logout ,上面修改了登录页地址后回跳到 /loginPage?logout
.logout()
// 无效会话
.invalidateHttpSession(true)
// 清除身份验证
.clearAuthentication(true)
// 删除cookie
.deleteCookies()
.permitAll()
.and()
// 权限不足跳转 /401
.exceptionHandling().accessDeniedPage("/401")
.and()
.csrf().disable();
}
@Bean
public static BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
用数据库存储,实现 UserDetailsService 接口
获取用户信息,先通过用户名获取用户,再通过用户id获取权限值,再设值
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private MenuService menuService;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userService.getByUsername(s);
if (user != null) {
List<String> authorities = menuService.getPermissionByUserId(user.getId());
return new SecurityUserDetails(user.getUsername(), user.getPassword(), user.getEnable(), authorities);
}
return null;
}
}
存储用户信息,实现 UserDetails 接口,UserDetails 是提供用户信息的核心接口,但仅存储用户信息,需要将用户信息封装到认证对象 Authentication 中
使用 SimpleGrantedAuthority ,GrantedAuthority 的基本实现来保存权限值
public class SecurityUserDetails implements UserDetails {
private String username;
private String password;
private Integer enable;
private List<GrantedAuthority> authorities;
public SecurityUserDetails (String username, String password, Integer enable, List<String> authorities) {
this.username = username;
this.password = password;
this.enable = enable;
// 权限值,在这里就是菜单的权限值
List<GrantedAuthority> authorityList = new ArrayList<>();
if (!authorities.isEmpty()) {
for (String authority : authorities) {
authorityList.add(new SimpleGrantedAuthority(authority));
}
}
this.authorities = authorityList;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return 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.enable == 1;
}
}
以上省略model,mapper,service等
page1~page6的权限控制
需要再Spring Security的配置中添加注解 @EnableGlobalMethodSecurity(prePostEnabled = true),开启方法级别保护
hasAuthority中是权限值,有相应的权限值才能访问该接口。所以在页面控制器上设置,即可控制页面的权限。
@Controller
public class PageController {
@PreAuthorize("hasAuthority('PageController:page1')")
@RequestMapping("/page1")
public ModelAndView page1() {
return new ModelAndView("page/page1");
}
@PreAuthorize("hasAuthority('PageController:page2')")
@RequestMapping("/page2")
public ModelAndView page2() {
return new ModelAndView("page/page2");
}
@PreAuthorize("hasAuthority('PageController:page3')")
@RequestMapping("/page3")
public ModelAndView page3() {
return new ModelAndView("page/page3");
}
@PreAuthorize("hasAuthority('PageController:page4')")
@RequestMapping("/page4")
public ModelAndView page4() {
return new ModelAndView("page/page4");
}
@PreAuthorize("hasAuthority('PageController:page5')")
@RequestMapping("/page5")
public ModelAndView page5() {
return new ModelAndView("page/page5");
}
@PreAuthorize("hasAuthority('PageController:page6')")
@RequestMapping("/page6")
public ModelAndView page6() {
return new ModelAndView("page/page6");
}
}
测试
添加用户:管理员,张三,李四
添加角色:管理员,测试员1,测试员2
添加菜单:page1~6
添加用户角色关联:
管理员 - 管理员
张三 - 测试员1
李四 - 测试员2
添加角色菜单关联:
管理员 page1~6
测试员1 page1,page2
测试员2 page1,page3
@Test
public void insertUser() {
User admin = new User();
admin.setNickname("管理员");
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin"));
User zhangsan = new User();
zhangsan.setNickname("张三");
zhangsan.setUsername("zhangsan");
zhangsan.setPassword(passwordEncoder.encode("zhangsan"));
User lisi = new User();
lisi.setNickname("李四");
lisi.setUsername("lisi");
lisi.setPassword(passwordEncoder.encode("lisi"));
userMapper.insert(admin);
userMapper.insert(zhangsan);
userMapper.insert(lisi);
}
@Test
public void insertRole() {
Role admin = new Role();
admin.setRoleName("管理员");
Role test1 = new Role();
test1.setRoleName("测试员1");
Role test2 = new Role();
test2.setRoleName("测试员2");
roleMapper.insert(admin);
roleMapper.insert(test1);
roleMapper.insert(test2);
}
@Test
public void insertMenu() {
for (int i = 1;i <= 6; i++) {
Menu menu = new Menu();
menu.setMenuName("菜单page" + i);
menu.setUrl("page/page" + i);
menu.setPermission("PageController:page" + i);
menuMapper.insert(menu);
}
}
@Test
public void insertUserRole() {
UserRole admin = new UserRole();
admin.setUserId(1);
admin.setRoleId(1);
UserRole zhangsan = new UserRole();
zhangsan.setUserId(2);
zhangsan.setRoleId(2);
UserRole lisi = new UserRole();
lisi.setUserId(3);
lisi.setRoleId(3);
userRoleMapper.insert(admin);
userRoleMapper.insert(zhangsan);
userRoleMapper.insert(lisi);
}
@Test
public void insertRoleMenu() {
for (int i = 1; i <= 6; i++) {
RoleMenu admin = new RoleMenu();
admin.setRoleId(1);
admin.setMenuId(i);
roleMenuMapper.insert(admin);
if (i == 1 || i == 2) {
RoleMenu test1 = new RoleMenu();
test1.setRoleId(2);
test1.setMenuId(i);
roleMenuMapper.insert(test1);
}
if (i == 1 || i == 3) {
RoleMenu test2 = new RoleMenu();
test2.setRoleId(3);
test2.setMenuId(i);
roleMenuMapper.insert(test2);
}
}
}
/ /home 有无权限都可访问,访问 / /home 不受security保护,
要访问page1会被spring security拦截,需要权限。所以跳到登录页登录,登录成功后,如果没有修改登录成功的Handler,会默认跳到之前被拦截的地址。
使用zhangsan也可以访问page1,因为zhangsan有page1的权限
使用zhangsan访问page3失败,因为没有zhangsan没有page3的权限
1.1 获取当前用户
SecurityContext 存储认证对象 Authentication,可获取当前用户信息
public SecurityUserDetails getCurrentUser() {
SecurityUserDetails securityUserDetails = null;
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
authentication.getDetails();
Object principal = authentication.getPrincipal();
if (principal instanceof String) {
// 未登录为anonymousUser
if ("anonymousUser".equals(principal)) {
return securityUserDetails;
}
} else {
securityUserDetails = (SecurityUserDetails) principal;
}
return securityUserDetails;
}
1.2 登录成功/失败Handler
登录成功Handler
重写登录成功的Handler,可对其进行扩展,比如添加登录日志等。默认会在登录成功后,重定向到之前的地址
Spring Security会在Session中存着原先的跳转页面,获取以后要删掉,防止错误。
@Component
public class LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Value("${server.servlet.context-path:'/'}")
private String redirectUrl;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// todo 记录登录日志等
// 获取原先的跳转页面
SavedRequest savedRequest = (SavedRequest) request.getSession().getAttribute("SPRING_SECURITY_SAVED_REQUEST");
if (savedRequest != null) {
redirectUrl = savedRequest.getRedirectUrl();
request.getSession().removeAttribute("SPRING_SECURITY_SAVED_REQUEST");
}
Map<String, Object> map = new HashMap<>();
map.put("code", 0);
map.put("msg", "登录成功!");
map.put("data", redirectUrl);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(map));
}
}
登录失败Handler
重写登录失败的Handler,可对其进行扩展,比如添加登录日志等
@Component
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
// todo 记录登录日志等
String errorMsg = "";
exception.printStackTrace();
if (exception instanceof BadCredentialsException) {
errorMsg = "账号密码错误!";
} else if (exception instanceof LockedException) {
errorMsg = "该账号被锁定!";
} else if (exception instanceof AccountExpiredException) {
errorMsg = "账户已过期!";
} else if (exception instanceof DisabledException) {
errorMsg = "该账户不可用!";
} else if (exception instanceof InternalAuthenticationServiceException) {
errorMsg = "账户异常,请联系管理员!";
} else {
errorMsg = "系统异常!";
}
Map<String, Object> map = new HashMap<>();
map.put("code", 401);
map.put("msg", errorMsg);
map.put("data", exception.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(map));
}
}
配置登陆成功/失败Handler
@Autowired
private LoginSuccessHandler loginSuccessHandler;
@Autowired
private LoginFailureHandler loginFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.formLogin()
...
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
...
}
1.3 限制最大登录数
通过限制Session的数量来限制单用户的登录数。
因为SpringSecurity是通过管理UserDetails对象来实现用户管理的,并且类的比较是不能用==比较的,类之间的比较是通过类的equals方法进行比较的。
我们自定义的 SecurityUserDetails 对象是没有实现 equals 方法的,所以要重写。关于重写的规则是:如果要重写 equals 方法,那么就必须要重写 toString 方法,如果要重写 toString 方法就最好要重写 hashCode 方法,所以我们需要在自定义的 SecurityUserDetails 对象中重写三个方法,hashCode、toString 和 equals 方法。
@Override
public boolean equals(Object o) {
return this.toString().equals(o.toString());
}
@Override
public int hashCode() {
return username.hashCode();
}
@Override
public String toString() {
return this.username;
}
修改Spring Security配置,添加以下代码
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement().maximumSessions(1)
// 当达到最大值时,是否保留已经登录的用户。
// true,新用户无法登录,可到登陆失败handler处理
// false,旧用户被踢出,被CustomExpiredStrategy处理
.maxSessionsPreventsLogin(false)
// 当达到最大值时,旧用户被踢出后的操作
.expiredSessionStrategy(new CustomExpiredStrategy());
}
用户被踢出时,也可以是Session过期后的操作,可以返回信息或者直接重定向到登录页
public class CustomExpiredStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
// Map<String, Object> map = new HashMap<>(2);
// map.put("code", -1);
// map.put("msg", "您已在另一台机器上登录。" );
//
// event.getResponse().setContentType("application/json;charset=UTF-8");
// event.getResponse().getWriter().write(JSON.toJSONString(map));
event.getResponse().sendRedirect("./loginPage");
}
}
测试发现:同一个浏览器算一个Session,不同的浏览器算另一个Session。
这里的Session还是存在Tomcat内存中,可以使用Spring Session结合Redis实现分布式Session,接管Tomcat内存中的Session。
Spring Session + Redis 使用
1.4 记住我
Session能够保存用户的状态,如果用户在一直在操作,Session就会不断更新过期时间。
如果用户用浏览器打开网页后,两天内没有操作,那么Session大概率过期了(Session过期时间默认30分钟)。
如果用户在登录时点击七天内免登录,那么在两天后刷新该标签页,依然有用户的登录状态;除非七天内没登陆。
登录页面添加记住我checkbox,再修改 WebSecurityConfig
@Autowired
private DataSource dataSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
// 使用表单登录
.formLogin()
...
.and()
// 记住我
.rememberMe()
// 自定义remember-me参数
.rememberMeParameter("login_remember_me")
// 配置从数据库persistent_logins中读取
.tokenRepository(persistentTokenRepository())
// rememberMe 的有效时间
.tokenValiditySeconds(100)
.userDetailsService(userDetailsService)
.and()
...
}
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// true 会自动创建persistent_logins表,若已创建,则注释掉否则会报错。
//tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
1.4.1 记住我流程
登录
在 UsernamePasswordAuthenticationFilter 验证完账号密码后,父类 AbstractAuthenticationProcessingFilter 的 doFilter 方法会执行
successfulAuthentication(request, response, chain, authResult);
在该方法中,会保存 Authentication 到 SecurityContext 中,调用 RememberMeServices 相关 service 处理
rememberMeServices.loginSuccess(request, response, authResult);
AbstractRememberMeServices 判断是否有 remember-me 参数,再由子类 PersistentTokenBasedRememberMeServices 的 onLoginSuccess 方法处理。在这里创建 PersistentRememberMeToken,写到表中,添加cookie,cookie值由series和token加密后组合而成。
登录成功之后
登录成功之后的请求会经过 RememberMeAuthenticationFilter,它是介于 UsernamePasswordAuthenticationFilter 和 AnonymousAuthenticationFilter 之间的一个filter。
当SecurityContext中的authentication没有的时候(Session过期),在 AbstractRememberMeServices 中会尝试自动登录
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,response);
从记住我的Cookie中获取token信息并解码
user = processAutoLoginCookie(cookieTokens, request, response);
在 PersistentTokenBasedRememberMeServices 的 processAutoLoginCookie 方法中,根据series获取 PersistentRememberMeToken,判断失效时间,更新token的值和失效时间。然后获取登录用户名,然后 UserDetailsServcie 加载 UserDetails 信息 ,创建Authticaton(RememberMeAuthenticationToken) 信息,再调用 AuthenticationManager.authenticate() 进行认证过程。
总结
当登录成功后,Session过期,记住我未过期,会通过记住我,继续保持登录状态,并更新记住我和Session;
当登录成功后,记住我过期,但是一直续订Session,会继续保持登录状态,直到Session过期
Session是保存短时状态;记住我长时状态。
Session过期前操作会续订;记住我在重新登录时创建新的一条,或是Session过期后,记住我过期前续订。
1.5 登录添加额外参数
在 Spring Security 初始化核心过滤器时,HttpSecurity 会通过将 Spring Security 内置的一些过滤器以 FilterComparator 提供的规则进行比较按照比较结果进行排序注册。FilterComparator 维护了一个顺序的注册表 filterToOrder 。
通过过滤器的类全限定名从注册表 filterToOrder 中获取自己的序号,如果没有直接获取到序号通过递归获取父类在注册表中的序号作为自己的序号,序号越小优先级越高。filterToOrder 中的过滤器并非全部会被初始化。有的需要额外引入一些功能包,有的看 HttpSecurity 的配置情况。
使用用户名密码默认会被 UsernamePasswordAuthenticationFilter 拦截,它是处理用户以及密码认证的核心过滤器。认证请求提交的username和password,被封装成token进行一系列的认证,便是主要通过这个过滤器完成的,在表单认证的方法中,这是最最关键的过滤器。
1.5.1 用户密码认证流程
UsernamePasswordAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter,在父类doFilter方法中,会调用子类实现的 attemptAuthentication 方法,获取认证信息
authResult = attemptAuthentication(request, response);
attemptAuthentication 方法中,将用户名和密码封装成token并认证,并添加额外信息后,进行认证
this.getAuthenticationManager().authenticate(authRequest);
getAuthenticationManager() 获取 AuthenticationManager 的实现类 ProviderManager,在 authenticate 方法中,找到合适的 AuthenticationProvider 处理认证,这里是DaoAuthenticationProvider,它父类 AbstractUserDetailsAuthenticationProvider 实现了该方法
result = provider.authenticate(authentication);
父类会调用 retrieveUser() 检索用户,实现在 DaoAuthenticationProvider
user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);
在 DaoAuthenticationProvider 中就会调用 UserDetailsService 的实现类的方法,从数据库获取该用户的信息,该接口都会重写
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
获取到用户后,进行密码校验,该方法中会调用 passwordEncoder 的 matches(),进行密码匹配
additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);
成功后,将信息保存到 Authentication,并返回。调用成功Handler,记住我等等。
通过上方分析得知,请求会被 UsernamePasswordAuthenticationFilter 拦截,会将用户名和密码封装成token,还可以从request中获取并添加额外信息。
authRequest.setDetails(authenticationDetailsSource.buildDetails(request))
这个额外信息存在 WebAuthenticationDetails 类中,并通过 WebAuthenticationDetailsSource 方法buildDetails创建,该方法又实现自AuthenticationDetailsSource<C, T>
所以,若想要添加自定义的额外信息,需要继承 WebAuthenticationDetails,并实现 AuthenticationDetailsSource<C, T> 接口中的方法。
1.5.2 实现
实现自定义的 WebAuthenticationDetails
该类提供了获取用户登录时携带的额外信息的功能,默认实现 WebAuthenticationDetails 提供了 remoteAddress 与 sessionId 信息,可以通过 Authentication的getDetails() 获取 WebAuthenticationDetails。
实现自定义类 CustomWebAuthenticationDetails 继承自 WebAuthenticationDetails,添加用户类型type
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
private String type;
public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
this.type = request.getParameter("type");
}
public String getType() {
return type;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(super.toString()).append("; ");
sb.append("type: ").append(this.getType());
return sb.toString();
}
}
实现自定义的 AuthenticationDetailsSource
该接口用于在Spring Security登录过程中对用户的登录信息的详细信息进行填充,默认实现是 WebAuthenticationDetailsSource,生成上面的默认实现 WebAuthenticationDetails。实现 AuthenticationDetailsSource,用于生成上面自定义的 CustomWebAuthenticationDetails。
@Component
public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, CustomWebAuthenticationDetails> {
@Override
public CustomWebAuthenticationDetails buildDetails(HttpServletRequest context) {
return new CustomWebAuthenticationDetails(context);
}
}
使用自定义的 AuthenticationDetailsSource
配置使用自定义的 AuthenticationDetailsSource,将自定义的 AuthenticationDetailsSource 注入进去
protected void configure(HttpSecurity http) throws Exception {
http
...
// 使用表单登录
.formLogin()
...
// 配置自定义authenticationDetailsSource,用于添加额外参数
.authenticationDetailsSource(authenticationDetailsSource)
...
}
获取到了额外信息,也需要认证,这里可以实现自定义的 AuthenticationProvider。AuthenticationProvider 提供登录验证处理逻辑,我们实现该接口编写自己的验证逻辑。
或者直接继承 AbstractUserDetailsAuthenticationProvider,就是 DaoAuthenticationProvider 的父类。修改他检索用户的方法retrieveUser
这里 CustomDaoAuthenticationProvider 大部分代码直接拷贝自 DaoAuthenticationProvider
但有修改了几个地方:
- passwordEncoder 原在构造器方法中设值,导致注入不到具体的 passwordEncoder,这里改成直接注入,因为 passwordEncoder 已在 WebSecurityConfig 中生成了bean。如果使用自定义的 passwordEncoder,也可在这里直接注入。
- 删掉了接口 createSuccessAuthentication,该方法在父类中也有实现,在原覆盖中是为了判断 passwordEncoder 是否有再编码,没有的话,还是调用父类方法。
- 直接注入多个 UserDetailsService 实现,根据类型判断选择
- 多余的getter,setter也都去掉了
@Component
public class CustomDaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
private volatile String userNotFoundEncodedPassword;
/**
* 直接注入passwordEncoder
*/
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private Type1UserDetailsServiceImpl type1UserDetailsService;
@Autowired
private Type2UserDetailsServiceImpl type2UserDetailsService;
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
/**
* 检索用户
* @param username
* @param authentication
* @return
* @throws AuthenticationException
*/
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = getUserDetails(username, authentication);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
/**
* 准备定时攻击防护
*/
private void prepareTimingAttackProtection() {
if (this.userNotFoundEncodedPassword == null) {
this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
}
}
/**
* 缓解定时攻击
* @param authentication
*/
private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
}
}
/**
* 根据用户类型获取用户信息
* @param username
* @param authentication
* @return
*/
private UserDetails getUserDetails(String username, UsernamePasswordAuthenticationToken authentication) {
UserDetails loadedUser = null;
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
if(details != null){
String type = details.getType();
// 不同类型可能实现不同的UserDetailsService接口,获取用户信息,可通过注入的方式
if ("0".equals(type)) {
loadedUser = userDetailsService.loadUserByUsername(username);
} else if ("1".equals(type)) {
loadedUser = type1UserDetailsService.loadUserByUsername(username);
// 类型2
} else if ("2".equals(type)) {
loadedUser = type2UserDetailsService.loadUserByUsername(username);
}
} else {
loadedUser = userDetailsService.loadUserByUsername(username);
}
return loadedUser;
}
}
1.6 API加上token认证
这里的token认证,并不是用token完全替代cookie和session,而只是添加一个拦截器,过滤/api/**并验证token。还是使用cookie和session的方式,通过security的过滤器链认证授权。
token使用用户id,在拦截器中,将Access-token中的token和SecurityContext中的用户认证信息对比,判断是否通过。
流程
访问受保护页面,未登录,返回登录页,登录成功,前端重定向根路径并带上原请求地址,在根路径中把token信息放进去后重定向原请求地址。前端获取到token,带上token获取用户权限信息,可访问的菜单等。
2. RBAC1
RBAC1 在 RBAC0 的基础之上引入了角色继承的概念,有了继承那么角色就有了上下级或者等级关系。父角色拥有其子角色所有的许可。通俗讲就是来说: 你能干的,你的领导一定能干,反过来就不一定能行。
需要对表进行修改:
新增部门表,部门有上下级;
用户有所属的部门,并有所创建的用户;
角色有所创建的用户。
表 | 字段 |
---|---|
用户表 user | id,nickname,username,password,enable,department_id,create_user_id |
角色表 role | id,role_name,create_user_id |
菜单表 menu | id,menu_name,url,permission |
用户角色关联表 user_role | id,user_id,role_id |
角色菜单关联表 role_menu | id,role_id,menu_id |
部门表 department | id,department_name,pid |
当有具体的表的管理页面的时候
页面 | 表格数据 |
---|---|
部门 | 当前用户所属部门以及下属部门 |
用户 | 当前用户所创建用户 |
角色 | 当前用户所创建角色 |
菜单 | 当前用户所属的角色拥有的权限 |
假设
现有部门A和B,B为A的子部门
管理员创建用户A,所属部门A,创建角色A给用户A,分配了page1,2,3页面权限
用户A创建用户B,所属部门B,创建角色B给用户B,分配了page1,2页面权限
当管理员剥夺角色A的page1权限,需要获取属于角色A的用户A,并获取其创建的角色B,将page1权限删除。
当存在多级时,需要递归删除。
可在菜单角色关联表添加字段create_user_id,用于保存用户id,方便回收权限。
当管理员修改用户A的角色时,需要获取用户A所创建的当前角色,将该角色权限进行调整。
当存在多级时,需要递归调整。
3. RBAC2
在体育比赛中,你不可能既是运动员又是裁判员!
这是很有名的一句话。反应了我们经常出现的一种职务(其实也就是角色)冲突。有些角色产生的历史原因就是为了制约另一个角色,裁判员就是为了制约运动员从而让运动员按照规范去比赛。如果一个人兼任这两个角色,比赛必然容易出现不公正的情况从而违背竞技公平性准则。还有就是我们每个人在不同的场景都会充当不同的角色,在公司你就是特定岗位的员工,在家庭中你就是一名家庭成员。随着场景的切换,我们的角色也在随之变化。
所以 RBAC2 在 RBAC0 的基础上引入了静态职责分离(Static Separation of Duty,简称SSD)和动态职责分离(Dynamic Separation of Duty,简称DSD)两个约束概念。他们两个作用的生命周期是不同的,
SSD 作用于约束用户和角色绑定时。 1.互斥角色:就像上面的例子你不能既是A又是B,互斥的角色只能二选一 ; 2. 数量约束:用户的角色数量是有限的不能多于某个基数; 3. 条件约束:只能达到某个条件才能拥有某个角色。经常用于用户等级体系,只有你充钱成为VIP才能一刀999。
DSD 作用于会话和角色交互时。当用户持有多个角色,在用户通过会话激活角色时加以条件约束,根据不同的条件执行不同的策略。
4. RBAC3
RBAC3 = RBAC1 + RBAC2
5. 数据权限
如果要对某些数据添加权限,可通过两种方式来体现。
一种在数据表中添加创建人所属的部门,用户只能查看其所在部门及下属部门的数据。
另一种是通过手动分配某些数据(尤其是树形结构型数据)属于哪些部门,然后在查询时根据手动分配的关联关系,展示对应的数据。
为方便知道部门的层级,可在部门表中添加新字段部门编码。自动生成,,树形编码,一级用三位。如部门A编码:001,下属部门B编码就是:001002
这样在查询数据时,直接使用部门编码后置模糊匹配数据。
参考:
Spring Security 实战干货:RBAC权限控制概念的理解
spring security(七) session 并发,一个用户在线后其他的设备登录此用户失败
关于使用SpringSecurity不能设置Session并发无效、剔除前一个用户无效的核心解决方案
SpringBoot集成Spring Security(6)——登录管理
Spring Security 实战干货:内置 Filter 全解析
Spring Security在登录验证中增加额外数据(如验证码)
Spring Security 解析(三) —— 个性化认证 以及 RememberMe 实现