项目结构调整 x 15 : 将 web、security、operatelog 等组件拆分出去

This commit is contained in:
YunaiV
2021-05-02 00:41:21 +08:00
parent 750ce01ecd
commit 20066bc864
151 changed files with 575 additions and 313 deletions

View File

@ -0,0 +1,52 @@
package cn.iocoder.yudao.framework.security.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.time.Duration;
@ConfigurationProperties(prefix = "yudao.security")
@Validated
@Data
public class SecurityProperties {
/**
* HTTP 请求时,访问令牌的请求 Header
*/
@NotEmpty(message = "Token Header 不能为空")
private String tokenHeader;
/**
* Token 过期时间
*/
@NotNull(message = "Token 过期时间不能为空")
private Duration tokenTimeout;
/**
* Token 秘钥
*/
@NotEmpty(message = "Token 秘钥不能为空")
private String tokenSecret;
/**
* Session 过期时间
*
* 当 User 用户超过当前时间未操作,则 Session 会过期
*/
@NotNull(message = "Session 过期时间不能为空")
private Duration sessionTimeout;
/**
* mock 模式的开关
*/
@NotNull(message = "mock 模式的开关不能为空")
private Boolean mockEnable;
/**
* mock 模式的秘钥
* 一定要配置秘钥,保证安全性
*/
@NotEmpty(message = "mock 模式的秘钥不能为空") // 这里设置了一个默认值,因为实际上只有 mockEnable 为 true 时才需要配置。
private String mockSecret = "yudaoyuanma";
}

View File

@ -0,0 +1,167 @@
package cn.iocoder.yudao.framework.security.config;
import cn.iocoder.yudao.framework.security.core.filter.JwtAuthenticationTokenFilter;
import cn.iocoder.yudao.framework.security.core.handler.LogoutSuccessHandlerImpl;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
/**
* spring security配置
*
* @author 芋道源码
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@EnableConfigurationProperties(SecurityProperties.class)
public class YudaoSecurityConfiguration extends WebSecurityConfigurerAdapter {
/**
* 自定义用户认证逻辑
*/
@Resource
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Resource
private AuthenticationEntryPoint unauthorizedHandler;
/**
* 权限不够处理器
*/
@Resource
private AccessDeniedHandler accessDeniedHandler;
/**
* 退出处理类
*/
@Resource
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* Token 认证过滤器
*/
@Resource
private JwtAuthenticationTokenFilter authenticationTokenFilter;
@Resource
private WebProperties webProperties;
@Value("${spring.boot.admin.context-path:''}")
private String adminSeverContextPath;
/**
* 由于 Spring Security 创建 AuthenticationManager 对象时,没声明 @Bean 注解,导致无法被注入
* 通过覆写父类的该方法,添加 @Bean 注解,解决该问题
*/
@Override
@Bean
@ConditionalOnMissingBean(AuthenticationManager.class)
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* Spring Security 加密器
* 考虑到安全性,这里采用 BCryptPasswordEncoder 加密器
*
* @see <a href="http://stackabuse.com/password-encoding-with-spring-security/">Password Encoding with Spring Security</a>
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
/**
* 配置 URL 的安全配置
*
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问非remember-me下自动登录
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数参数表示IP地址如果用户IP和参数匹配则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 开启跨域
.cors().and()
// CSRF 禁用,因为不使用 Session
.csrf().disable()
// 基于 token 机制,所以不需要 Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 一堆自定义的 Spring Security 处理器
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
.accessDeniedHandler(accessDeniedHandler).and()
// 设置每个请求的权限
.authorizeRequests()
// 登陆的接口,可匿名访问
.antMatchers(api("/login")).anonymous()
// 通用的接口,可匿名访问 TODO 芋艿:需要抽象出去
.antMatchers(api("/system/captcha/**")).anonymous()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
// 文件的获取接口,可匿名访问
.antMatchers(api("/infra/file/get/**")).anonymous()
// Swagger 接口文档
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
// Spring Boot Admin Server 的安全配置 TODO 芋艿:需要抽象出去
.antMatchers(adminSeverContextPath).anonymous()
.antMatchers(adminSeverContextPath + "/**").anonymous()
// Spring Boot Actuator 的安全配置
.antMatchers("/actuator").anonymous()
.antMatchers("/actuator/**").anonymous()
// Druid 监控 TODO 芋艿:需要抽象出去
.antMatchers("/druid/**").anonymous()
// 短信回调 API TODO 芋艿:需要抽象出去
.antMatchers(api("/system/sms/callback/**")).anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl(api("/logout")).logoutSuccessHandler(logoutSuccessHandler);
// 添加 JWT Filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
private String api(String url) {
return webProperties.getApiPrefix() + url;
}
}

View File

@ -0,0 +1,93 @@
package cn.iocoder.yudao.framework.security.core;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Date;
import java.util.Set;
/**
* 登陆用户信息
*
* @author 芋道源码
*/
@Data
public class LoginUser implements UserDetails {
/**
* 用户编号
*/
private Long id;
/**
* 科室编号
*/
private Long deptId;
/**
* 角色编号数组
*/
private Set<Long> roleIds;
/**
* 最后更新时间
*/
private Date updateTime;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 状态
*/
private Integer status;
@Override
@JsonIgnore// 避免序列化
public String getPassword() {
return password;
}
@Override
@JsonIgnore
public String getUsername() {
return username;
}
@Override
@JsonIgnore// 避免序列化
public boolean isEnabled() {
return CommonStatusEnum.ENABLE.getStatus().equals(status);
}
@Override
@JsonIgnore// 避免序列化
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
@JsonIgnore// 避免序列化
public boolean isAccountNonExpired() {
return true; // 返回 true不依赖 Spring Security 判断
}
@Override
@JsonIgnore// 避免序列化
public boolean isAccountNonLocked() {
return true; // 返回 true不依赖 Spring Security 判断
}
@Override
@JsonIgnore// 避免序列化
public boolean isCredentialsNonExpired() {
return true; // 返回 true不依赖 Spring Security 判断
}
}

View File

@ -0,0 +1,28 @@
package cn.iocoder.yudao.framework.security.core.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 数据范围枚举类
*
* 用于实现数据级别的权限
*
* @author 芋道源码
*/
@Getter
@AllArgsConstructor
public enum DataScopeEnum {
ALL(1), // 全部数据权限
DEPT_CUSTOM(2), // 指定部门数据权限
DEPT_ONLY(3), // 部门数据权限
DEPT_AND_CHILD(4), // 部门及以下数据权限
DEPT_SELF(5); // 仅本人数据权限
/**
* 范围
*/
private final Integer score;
}

View File

@ -0,0 +1,85 @@
package cn.iocoder.yudao.framework.security.core.filter;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.config.SecurityProperties;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.service.SecurityAuthFrameworkService;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import cn.iocoder.yudao.framework.util.servlet.ServletUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* JWT 过滤器,验证 token 的有效性
* 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文
*
* @author ruoyi
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private SecurityProperties securityProperties;
@Resource
private SecurityAuthFrameworkService authService;
@Resource
private GlobalExceptionHandler globalExceptionHandler;
@Override
@SuppressWarnings("NullableProblems")
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = SecurityFrameworkUtils.obtainAuthorization(request, securityProperties.getTokenHeader());
if (StrUtil.isNotEmpty(token)) {
try {
// 验证 token 有效性
LoginUser loginUser = authService.verifyTokenAndRefresh(token);
// 模拟 Login 功能,方便日常开发调试
if (loginUser == null) {
loginUser = this.mockLoginUser(token);
}
// 设置当前用户
if (loginUser != null) {
SecurityFrameworkUtils.setLoginUser(loginUser, request);
}
} catch (Throwable ex) {
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
ServletUtils.writeJSON(response, result);
return;
}
}
// 继续过滤链
chain.doFilter(request, response);
}
/**
* 模拟登陆用户,方便日常开发调试
*
* 注意,在线上环境下,一定要关闭该功能!!!
*
* @param token 模拟的 token格式为 {@link SecurityProperties#getTokenSecret()} + 用户编号
* @return 模拟的 LoginUser
*/
private LoginUser mockLoginUser(String token) {
if (!securityProperties.getMockEnable()) {
return null;
}
// 必须以 mockSecret 开头
if (!token.startsWith(securityProperties.getMockSecret())) {
return null;
}
Long userId = Long.valueOf(token.substring(securityProperties.getMockSecret().length()));
return authService.mockLogin(userId);
}
}

View File

@ -0,0 +1,43 @@
package cn.iocoder.yudao.framework.security.core.handler;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.util.servlet.ServletUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.stereotype.Component;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
/**
* 访问一个需要认证的 URL 资源,已经认证(登录)但是没有权限的情况下,返回 {@link GlobalErrorCodeConstants#FORBIDDEN} 错误码。
*
* 补充Spring Security 通过 {@link ExceptionTranslationFilter#handleAccessDeniedException(HttpServletRequest, HttpServletResponse, FilterChain, AccessDeniedException)} 方法,调用当前类
*
* @author 芋道源码
*/
@Component
@Slf4j
@SuppressWarnings("JavadocReference")
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
throws IOException, ServletException {
// 打印 warn 的原因是,不定期合并 warn看看有没恶意破坏
log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(),
SecurityFrameworkUtils.getLoginUser().getId(), e);
// 返回 403
ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED));
}
}

View File

@ -0,0 +1,37 @@
package cn.iocoder.yudao.framework.security.core.handler;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.util.servlet.ServletUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.stereotype.Component;
import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
/**
* 访问一个需要认证的 URL 资源,但是此时自己尚未认证(登录)的情况下,返回 {@link GlobalErrorCodeConstants#UNAUTHORIZED} 错误码,从而使前端重定向到登录页
*
* 补充Spring Security 通过 {@link ExceptionTranslationFilter#sendStartAuthentication(HttpServletRequest, HttpServletResponse, FilterChain, AuthenticationException)} 方法,调用当前类
*
* @author ruoyi
*/
@Component
@Slf4j
@SuppressWarnings("JavadocReference") // 忽略文档引用报错
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e);
// 返回 401
ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED));
}
}

View File

@ -0,0 +1,43 @@
package cn.iocoder.yudao.framework.security.core.handler;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.config.SecurityProperties;
import cn.iocoder.yudao.framework.security.core.service.SecurityAuthFrameworkService;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.util.servlet.ServletUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 自定义退出处理器
*
* @author ruoyi
*/
@Component
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
@Resource
private SecurityProperties securityProperties;
@Resource
private SecurityAuthFrameworkService securityFrameworkService;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 执行退出
String token = SecurityFrameworkUtils.obtainAuthorization(request, securityProperties.getTokenHeader());
if (StrUtil.isNotBlank(token)) {
securityFrameworkService.logout(token);
}
// 返回成功
ServletUtils.writeJSON(response, CommonResult.success(null));
}
}

View File

@ -0,0 +1,37 @@
package cn.iocoder.yudao.framework.security.core.service;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import org.springframework.security.core.userdetails.UserDetailsService;
/**
* Security 框架 Auth Service 接口,定义 security 组件需要的功能
*
* @author 芋道源码
*/
public interface SecurityAuthFrameworkService extends UserDetailsService {
/**
* 校验 token 的有效性,并获取用户信息
* 通过后,刷新 token 的过期时间
*
* @param token token
* @return 用户信息
*/
LoginUser verifyTokenAndRefresh(String token);
/**
* 模拟指定用户编号的 LoginUser
*
* @param userId 用户编号
* @return 登录用户
*/
LoginUser mockLogin(Long userId);
/**
* 基于 token 退出登录
*
* @param token token
*/
void logout(String token);
}

View File

@ -0,0 +1,44 @@
package cn.iocoder.yudao.framework.security.core.service;
/**
* Security 框架 Permission Service 接口,定义 security 组件需要的功能
*
* @author 芋道源码
*/
public interface SecurityPermissionFrameworkService {
/**
* 判断是否有权限
*
* @param permission 权限
* @return 是否
*/
boolean hasPermission(String permission);
/**
* 判断是否有权限,任一一个即可
*
* @param permissions 权限
* @return 是否
*/
boolean hasAnyPermissions(String... permissions);
/**
* 判断是否有角色
*
* 注意,角色使用的是 SysRoleDO 的 code 标识
*
* @param role 角色
* @return 是否
*/
boolean hasRole(String role);
/**
* 判断是否有角色,任一一个即可
*
* @param roles 角色数组
* @return 是否
*/
boolean hasAnyRoles(String... roles);
}

View File

@ -0,0 +1,102 @@
package cn.iocoder.yudao.framework.security.core.util;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Set;
/**
* 安全服务工具类
*
* @author 芋道源码
*/
public class SecurityFrameworkUtils {
private SecurityFrameworkUtils() {}
/**
* 从请求中,获得认证 Token
*
* @param request 请求
* @param header 认证 Token 对应的 Header 名字
* @return 认证 Token
*/
public static String obtainAuthorization(HttpServletRequest request, String header) {
String authorization = request.getHeader(header);
if (!StringUtils.hasText(authorization)) {
return null;
}
int index = authorization.indexOf("Bearer ");
if (index == -1) { // 未找到
return null;
}
return authorization.substring(index + 7).trim();
}
/**
* 获取当前用户
*
* @return 当前用户
*/
@Nullable
public static LoginUser getLoginUser() {
SecurityContext context = SecurityContextHolder.getContext();
if (context == null) {
return null;
}
Authentication authentication = context.getAuthentication();
if (authentication == null) {
return null;
}
return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null;
}
/**
* 获得当前用户的编号,从上下文中
*
* @return 用户编号
*/
@Nullable
public static Long getLoginUserId() {
LoginUser loginUser = getLoginUser();
return loginUser != null ? loginUser.getId() : null;
}
/**
* 获得当前用户的角色编号数组
*
* @return 角色编号数组
*/
@Nullable
public static Set<Long> getLoginUserRoleIds() {
LoginUser loginUser = getLoginUser();
return loginUser != null ? loginUser.getRoleIds() : null;
}
/**
* 设置当前用户
*
* @param loginUser 登陆用户
* @param request 请求
*/
public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) {
// 创建 UsernamePasswordAuthenticationToken 对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUser, null, null);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 设置到上下文
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号;
// 原因是Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息
WebFrameworkUtils.setLoginUserId(request, loginUser.getId());
}
}

View File

@ -0,0 +1,7 @@
/**
* 基于 Spring Security 框架
* 实现安全认证功能
*
* @author 芋道源码
*/
package cn.iocoder.yudao.framework.security;