文章目录
- Spring Security
- 权限控制
- 置顶、加精、删除
- Redis高级数据类型
- 网站数据统计
使用Spring Security进行权限控制,对登录检查功能进行了重写。对不同的登录账号授予不同的权限,实现了置顶、加精、删除功能。使用Redis高级数据类型HyperLogLog和Bitmap实现了网站UV(独立访客)和DAU(日活跃用户)数据的统计。
Spring Security
-
简介:Spring Security是一个专注于为Java应用程序提供身份认证和授权的框架,它的强大之处在于它可以轻松扩展以满足自定义的需求。
-
Spring Security底层是基于Filter实现的,Filter与 DispatcherServlet的关系类似于Interceptor与Controller的关系。
-
特征:
- 对身份的认证和授权提供全面的、可扩展的支持。
- 防止各种攻击,如会话固定攻击、点击劫持、csrf攻击等。
- 支持与Servlet API、Spring MVC等Web技术集成。
-
使用(以登录验证为例)
-
导入依赖spring-boot-starter-security,添加完该依赖以后,spring security就已经生效了,重启项目,再次打开首页,会自动跳转到一个登录页面,这个登录页面是Spring security自带的,我们可以用自己写的登录页面替换它。Spring security会自动生成一个用户和密码,用于默认登录页面的验证。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
-
对实体类进行处理,让跟登录相关的实体类实现UserDetails接口中的一些方法,(isAccountNonExpired:判断账号是否过期;isAccountNonLocked:判断账号是否锁定;isCredentialsNonExpired:判断凭证是否过期;isEnabled:判断账号是否可用;getAuthorities:获得用户拥有的权限)
// 示例 public class User implements UserDetails { private int id; private String username; private String password; private String salt; private String email; private int type; private int status; private String activationCode; private String headerUrl; private Date createTime; // 此处省略 get方法、set方法、toString方法 // true : 账号未过期 @Override public boolean isAccountNonExpired() { return true; } // true : 账号未锁定 @Override public boolean isAccountNonLocked() { return true; } // true: 凭证未过期 @Override public boolean isCredentialsNonExpired() { return true; } // true: 账号可用 @Override public boolean isEnabled() { return true; } // 权限 @Override public Collection<? extends GrantedAuthority> getAuthorities() { List<GrantedAuthority> list = new ArrayList<>(); list.add(new GrantedAuthority() { @Override public String getAuthority() { switch (type){ case 1: return "ADMIN"; default: return "USER"; } } }); return list; } }
-
将业务层的相关类实现UserDetailsService接口中的loadUserByUsername方法。
// 示例 @Service public class UserService implements UserDetailsService { @Autowired private UserMapper userMapper; public User findUserByName(String username) { return userMapper.selectByName(username); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return this.findUserByName(username); } }
-
配置SecurityConfig配置类,该类继承WebSecurityConfigurerAdapter,重写3个config方法。包括认证的逻辑(自定义认证规则),授权的逻辑(登录相关的配置,退出相关的配置,授权配置,增加Filter,添加记住我功能等)。
// 示例 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserService userService; @Override public void configure(WebSecurity web) throws Exception { // 忽略静态资源的访问 web.ignoring().antMatchers("/resources/**"); } // 认证的逻辑 // AuthenticationManager:认证的核心接口。 // AuthenticationManagerBuilder:用于构建AuthenticationManager对象的工具。 // ProviderManager:AuthenticationManager接口的默认实现类。 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 内置的认证规则 // auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345")); // 自定义认证规则 // AuthenticationProvider:ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证。 // 委托模式:ProviderManager将认证委托给AuthenticationProvider。 auth.authenticationProvider(new AuthenticationProvider() { // Authentication:用于封装认证信息的接口,不同的实现类代表不同类型的认证信息。 @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = authentication.getName(); String password = (String) authentication.getCredentials(); User user = userService.findUserByName(username); if(user == null){ throw new UsernameNotFoundException("账号不存在!"); } password = CommunityUtil.md5(password+user.getSalt()); if(!user.getPassword().equals(password)){ throw new BadCredentialsException("密码不正确!"); } // principal:认证的主要信息; credentials:证书; authorities:权限 return new UsernamePasswordAuthenticationToken(user,user.getPassword(),user.getAuthorities()); } //当前的AuthenticationProvider支持哪种类型的认证。 @Override public boolean supports(Class<?> aClass) { // UsernamePasswordAuthenticationToken:Authentication接口的常用的实现类,账号密码认证。 return UsernamePasswordAuthenticationToken.class.equals(aClass); } }); } // 授权的逻辑 @Override protected void configure(HttpSecurity http) throws Exception { // 登录相关的配置 http.formLogin() .loginPage("/loginpage") .loginProcessingUrl("/login") .successHandler(new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 重定向 response.sendRedirect(request.getContextPath()+"/index"); } }) .failureHandler(new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { // 转发 request.setAttribute("error",e.getMessage()); request.getRequestDispatcher("/loginpage").forward(request,response); } }); // 退出相关配置 http.logout() .logoutUrl("/logout") .logoutSuccessHandler(new LogoutSuccessHandler() { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.sendRedirect(request.getContextPath() + "/index"); } }); // 授权配置 http.authorizeRequests() .antMatchers("/letter").hasAnyAuthority("USER","ADMIN") .antMatchers("/admin").hasAnyAuthority("ADMIN") .and().exceptionHandling().accessDeniedPage("/denied"); // 增加Filter,处理验证码 http.addFilterBefore(new Filter() { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; if(request.getServletPath().equals("/login")){ String verifyCode = request.getParameter("verifyCode"); System.out.println("验证码:" + verifyCode); if(verifyCode == null || !verifyCode.equalsIgnoreCase("1234")){ request.setAttribute("error","验证码错误!"); request.getRequestDispatcher("/loginpage").forward(request,response); return ; } } // 让请求继续向下执行 filterChain.doFilter(request,response); } }, UsernamePasswordAuthenticationFilter.class); // 记住我 http.rememberMe() .tokenRepository(new InMemoryTokenRepositoryImpl()) .tokenValiditySeconds(3600 * 24) .userDetailsService(userService); } }
注意⚠️
认证成功后,结果会通过SecurityContextHolder存入SecurityContext中,访问相关信息的时候,需要SecurityContextHolder。
Security规定退出登录功能的请求方式必须是post。
记住我功能前端代码部分name必须是 remember-me。重定向与转发示意图:
-
权限控制
-
登录检查:之前采用拦截器实现了登录检查,这是简单的权限管理方案,现将其废弃。
实现:将WebMvcConfig配置类中跟登录检查相关的代码注释掉。
-
授权配置:对当前系统内包含的所有的请求,分配访问权限(普通用户user,版主moderator,管理员admin)。
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant { @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/resources/**"); } @Override protected void configure(HttpSecurity http) throws Exception { // 授权 http.authorizeRequests() .antMatchers( "/user/setting", "/user/upload", "/discuss/add", "/comment/add/**", "/letter/**", "/notice/**", "/like", "/follow", "/unfollow" ).hasAnyAuthority( AUTHORITY_USER, AUTHORITY_ADMIN, AUTHORITY_MODERATOR ).antMatchers( "/discuss/top", "/discuss/wonderful" ) .hasAnyAuthority( AUTHORITY_MODERATOR ).antMatchers( "/discuss/delete", "/data/**", "/actuator/**" ) .hasAnyAuthority( AUTHORITY_ADMIN ) .anyRequest().permitAll() .and().csrf().disable(); // 权限不够时的处理 http.exceptionHandling() .authenticationEntryPoint(new AuthenticationEntryPoint() { // 没有登录时的处理 @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { String xRequestedWith = request.getHeader("x-requested-with"); if("XMLHttpRequest".equals(xRequestedWith)){ // 异步时的处理 response.setContentType("application/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(CommunityUtil.getJSONString(403,"你还没有登录哦!")); }else{ response.sendRedirect(request.getContextPath() + "/login"); } } }) .accessDeniedHandler(new AccessDeniedHandler() { // 权限不足时的处理 @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { String xRequestedWith = request.getHeader("x-requested-with"); if("XMLHttpRequest".equals(xRequestedWith)){ // 异步时的处理 response.setContentType("application/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(CommunityUtil.getJSONString(403,"你没有访问此功能的权限!")); }else{ response.sendRedirect(request.getContextPath() + "/denied"); } } }); // Security底层默认会拦截/logout请求,进行退出处理. // 覆盖它默认的逻辑,才能执行我们自己的退出代码。 http.logout().logoutUrl("/securitylogout"); } }
-
认证方案:绕过Security认证流程,采用系统原来的认证方案。
在UserService业务层代码中对user进行授权
public Collection<? extends GrantedAuthority> getAuthorities(int userId){ User user = this.findUserById(userId); List<GrantedAuthority> list = new ArrayList<>(); list.add(new GrantedAuthority() { @Override public String getAuthority() { switch (user.getType()){ case 1: return AUTHORITY_ADMIN; case 2: return AUTHORITY_MODERATOR; default: return AUTHORITY_USER; } } }); return list; }
因为绕过了Security的认证流程(Serucity认证时会将认证结果存入SecurityContext中),所要构建用户认证的结果,并存入SecurityContext,以便于Security进行授权。在检查登录凭证的拦截器preHandle()方法中处理这一块逻辑。
// 构建用户认证的结果,并存入SecurityContext,以便于Security进行授权。 Authentication authentication = new UsernamePasswordAuthenticationToken( user,user.getPassword(),userService.getAuthorities(user.getId())); SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
请求结束时,将SecurityContext中保存的权限进行清理,实现:在拦截器afterCompletion()方法以及退出登录时,删除用户认证结果。
SecurityContextHolder.clearContext();
-
CSRF配置:防止CSRF攻击的基本原理,以及表单、AJAX相关的配置。
CSRF:Cross Site Request Forgery( 跨站请求伪造)
**CSRF攻击:**大部分的网站应用都是采用cookie或session的方式进行登入验证,当通过登入验证之后,网站就会给你一个通行证存在cookie或seesion中,代表之后的动作中都不需要重复验证身份了。当在登录一个网站以后,中途去逛了其他网页,刚好遇到了恶意网页,窃取了cookies,那么它就可以凭借该身份访问网站,这就是CSRF攻击。
防御CSRF的方式有两种,1.检查referer栏位,http协定中就有一个referer栏位记录着这个请求是从哪个网站发出来的,从而确认请求来源。2.加入验证token,这个token由服务端产生,加密存在session中,无法仿造。Spring Security就是采用的第二种方法。
⚠️:Spring Security默认防御CSRF攻击,但是对于异步请求不可以,因为异步请求根本不提交表单数据,因此,我们需要单独处理。
发布帖子就是一个异步请求,以此为例:
-
通过标签要求服务器把csrf凭证生成在header里,发请求的时候直接从header里取。
<!-- 访问该页面时,在此处生成CSRF令牌. --> <meta name="_csrf" th:content="${_csrf.token}"> <meta name="_csrf_header" th:content="${_csrf.headerName}">
-
发送AJAX请求之前,将CSRF令牌设置到请求消息头中。
// 发送AJAX请求之前,将CSRF令牌设置到请求消息头中. var token = $("meta[name='_csrf']").attr("content"); var header = $("meta[name='_csrf_header']").attr("content"); $(document).ajaxSend(function (e,xhr,options){ xhr.setRequestHeader(header,token); });
当Spring Security启用防御CSRF后,对于所有的异步请求都需要处理。也可以禁用security防御CSRF攻击的功能,在授权时加上
.and().csrf().disable();
即可。
置顶、加精、删除
-
功能实现
- 点击 “置顶”,修改帖子的类型。0-普通、1-置顶
- 点击 “加精”、“删除”、修改帖子的状态。 0-正常、1-精华、2-拉黑
-
权限管理
- 版主可以执行“置顶”、“加精”操作
- 管理员可以执行“删除”操作
-
按钮显示
- 版主可以看到“置顶”、“加精”按钮
- 管理员可以看到“删除”按钮
Redis高级数据类型
-
HyperLogLog(超级日志),可以对数据统计,也可以对数据合并
- 采用一种基数算法,用于完成独立总数的统计。
- 占据空间小,无论统计多少个数据,只占12K的内存空间。
- 不精确的统计算法,标准误差为0.81%。
// 示例 // 添加数据 redisTemplate.opsForHyperLogLog().add(redisKey,value); // 统计数据 Long size = redisTemplate.opsForHyperLogLog().size(redisKey); // 合并数据 redisTemplate.opsForHyperLogLog().union(unionKey,redisKey2,redisKey3,redisKey4);
-
Bitmap(位图),可以统计一组数据的布尔值
- 不是一种独立的数据结构, 实际上就是字符串。
- 支持安慰存取数据,可以将其看成是byte数组。
- 适合存储检索大量的连续的数据的布尔值。
// 示例 // 记录 redisTemplate.opsForValue().setBit(redisKey,index,value); // value:true or false // 查询 System.out.println(redisTemplate.opsForValue().getBit(redisKey,index)); // 统计 Object obj = redisTemplate.execute(new RedisCallback() { @Override public Object doInRedis(RedisConnection connection) throws DataAccessException { return connection.bitCount(redisKey.getBytes()); } }); System.out.println(obj); // 运算 以or为例 Object obj = redisTemplate.execute(new RedisCallback() { @Override public Object doInRedis(RedisConnection connection) throws DataAccessException { connection.bitOp(RedisStringCommands.BitOperation.OR,redisKey.getBytes(), redisKey2.getBytes(),redisKey3.getBytes(),redisKey4.getBytes()); return connection.bitCount(redisKey.getBytes()); } }); System.out.println(obj);
网站数据统计
-
UV(Unique Visitor)
- 独立访客,需通过用户IP排重统计数据。为什么用IP而不是用userId统计呢,因为我们希望将非注册用户(游客)也统计进来。
- 每次访问都要进行统计。
- HyperLogLog,性能好,且存储空间小。
-
DAU(Daily Active User)
- 日活跃用户,需通过用户ID排重统计数据。
- 访问过一次,则认为其活跃。
- Bitmap,性能好、且可以统计精确的结果。
具体实现:
1.定义RedisKey
public class RedisKeyUtil {
private static final String SPLIT = ":";
private static final String PREFIX_UV = "uv";
private static final String PREFIX_DAU = "dau";
// 单日UV
public static String getUVKey(String date){
return PREFIX_UV + SPLIT + date;
}
// 区间UV
public static String getUVKey(String startDate,String endDate){
return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
}
// 单日活跃用户
public static String getDAUKey(String date){
return PREFIX_DAU + SPLIT + date;
}
// 区间活跃用户
public static String getDAUKey(String startDate,String endDate){
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}
}
2.业务层逻辑
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");
// 将指定的IP计入UV
public void recordUV(String ip){
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey,ip);
}
// 统计指定日期范围内的UV
public long calculateUV(Date start,Date end){
if(start == null || end == null){
throw new IllegalArgumentException("参数不能为空!");
}
// 整理该日期范围内的key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)){
String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
keyList.add(key);
calendar.add(calendar.DATE,1);
}
// 合并这些数据
String redisKey = RedisKeyUtil.getUVKey(df.format(start),df.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());
// 返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
// 将指定用户计入DAU
public void recordDAU(int userId){
String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey,userId,true);
}
// 统计指定日期范围内的DAU
public long calculateDAU(Date start,Date end){
if(start == null || end == null){
throw new IllegalArgumentException("参数不能为空!");
}
// 整理该日期范围内的key
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)){
String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(calendar.DATE,1);
}
// 进行OR运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(df.format(start),df.format(end));
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(),keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});
}
}
3.使用拦截器进行访问数据的记录
- 定义拦截器DataInterceptor
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 统计UV
String ip = request.getRemoteHost();
dataService.recordUV(ip);
// 统计DAU
User user = hostHolder.getUser();
if(user != null){
dataService.recordDAU(user.getId());
}
return true;
}
}
- 配置拦截器WebMvcConfig
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private DataInterceptor dataInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
}
}
4.表现层逻辑
注意⚠️:@DateTimeFormat注解的作用是入参格式化,前台传string类型的时间字符串,此注解将字符串转换为Date类型。
@Controller
public class DataController {
@Autowired
private DataService dataService;
// 统计页面
@RequestMapping(path = "/data",method = {RequestMethod.GET,RequestMethod.POST})
public String getDataPage(){
return "/site/admin/data";
}
// 统计网站UV
@RequestMapping(path = "/data/uv",method = RequestMethod.POST)
public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model){
long uv = dataService.calculateUV(start,end);
model.addAttribute("uvResult",uv);
model.addAttribute("uvStartDate",start);
model.addAttribute("uvEndDate",end);
return "forward:/data";
}
// 统计活跃用户
@RequestMapping(path = "/data/dau",method = RequestMethod.POST)
public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model){
long dau = dataService.calculateDAU(start,end);
model.addAttribute("dauResult",dau);
model.addAttribute("dauStartDate",start);
model.addAttribute("dauEndDate",end);
return "forward:/data";
}
}
5.权限管理SecutiryConfig
// 部分授权代码
http.authorizeRequests()
.antMatchers(
"/data/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
.anyRequest().permitAll()
.and().csrf().disable();
}