This commit is contained in:
2022-04-18 11:33:40 +08:00
commit b1b46f46fa
2389 changed files with 192283 additions and 0 deletions
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-framework</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>yudao-spring-boot-starter-security</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>用户的认证、权限的校验</description>
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<dependencies>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-common</artifactId>
</dependency>
<!-- Spring 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Web 相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-spring-boot-starter-web</artifactId>
</dependency>
<!-- spring boot 配置所需依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- TODO 芋艿: -->
<dependency>
<groupId>org.activiti</groupId>
<artifactId>activiti-engine</artifactId>
<version>7.1.0.M6</version>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
<optional>true</optional>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.framework.security.config;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import org.springframework.core.Ordered;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import javax.annotation.Resource;
/**
* 自定义的 URL 的安全配置
* 目的:每个 Maven Module 可以自定义规则!
*
* @author 芋道源码
*/
public abstract class AuthorizeRequestsCustomizer
implements Customizer<ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry>, Ordered {
@Resource
private WebProperties webProperties;
protected String buildAdminApi(String url) {
return webProperties.getAdminApi().getPrefix() + url;
}
protected String buildAppApi(String url) {
return webProperties.getAppApi().getPrefix() + url;
}
@Override
public int getOrder() {
return 0;
}
}
@@ -0,0 +1,46 @@
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.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;
/**
* 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";
}
@@ -0,0 +1,117 @@
package cn.iocoder.yudao.framework.security.config;
import cn.iocoder.yudao.framework.security.core.aop.PreAuthenticatedAspect;
import cn.iocoder.yudao.framework.security.core.authentication.MultiUserDetailsAuthenticationProvider;
import cn.iocoder.yudao.framework.security.core.context.TransmittableThreadLocalSecurityContextHolderStrategy;
import cn.iocoder.yudao.framework.security.core.filter.JWTAuthenticationTokenFilter;
import cn.iocoder.yudao.framework.security.core.handler.AccessDeniedHandlerImpl;
import cn.iocoder.yudao.framework.security.core.handler.AuthenticationEntryPointImpl;
import cn.iocoder.yudao.framework.security.core.handler.LogoutSuccessHandlerImpl;
import cn.iocoder.yudao.framework.security.core.service.SecurityAuthFrameworkService;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import org.springframework.beans.factory.config.MethodInvokingFactoryBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.context.SecurityContextHolder;
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.logout.LogoutSuccessHandler;
import javax.annotation.Resource;
import java.util.List;
/**
* Spring Security 自动配置类,主要用于相关组件的配置
*
* 注意,不能和 {@link YudaoWebSecurityConfigurerAdapter} 用一个,原因是会导致初始化报错。
* 参见 https://stackoverflow.com/questions/53847050/spring-boot-delegatebuilder-cannot-be-null-on-autowiring-authenticationmanager 文档。
*
* @author 芋道源码
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SecurityProperties.class)
public class YudaoSecurityAutoConfiguration {
@Resource
private SecurityProperties securityProperties;
/**
* 处理用户未登录拦截的切面的 Bean
*/
@Bean
public PreAuthenticatedAspect preAuthenticatedAspect() {
return new PreAuthenticatedAspect();
}
/**
* 认证失败处理类 Bean
*/
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return new AuthenticationEntryPointImpl();
}
/**
* 权限不够处理器 Bean
*/
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return new AccessDeniedHandlerImpl();
}
/**
* 退出处理类 Bean
*/
@Bean
public LogoutSuccessHandler logoutSuccessHandler(MultiUserDetailsAuthenticationProvider authenticationProvider) {
return new LogoutSuccessHandlerImpl(securityProperties, authenticationProvider);
}
/**
* 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();
}
/**
* Token 认证过滤器 Bean
*/
@Bean
public JWTAuthenticationTokenFilter authenticationTokenFilter(MultiUserDetailsAuthenticationProvider authenticationProvider,
GlobalExceptionHandler globalExceptionHandler) {
return new JWTAuthenticationTokenFilter(securityProperties, authenticationProvider, globalExceptionHandler);
}
/**
* 身份验证的 Provider Bean,通过它实现账号 + 密码的认证
*/
@Bean
public MultiUserDetailsAuthenticationProvider authenticationProvider(
List<SecurityAuthFrameworkService> securityFrameworkServices,
WebProperties webProperties, PasswordEncoder passwordEncoder) {
return new MultiUserDetailsAuthenticationProvider(securityFrameworkServices, webProperties, passwordEncoder);
}
/**
* 声明调用 {@link SecurityContextHolder#setStrategyName(String)} 方法,
* 设置使用 {@link TransmittableThreadLocalSecurityContextHolderStrategy} 作为 Security 的上下文策略
*/
@Bean
public MethodInvokingFactoryBean securityContextHolderMethodInvokingFactoryBean() {
MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean();
methodInvokingFactoryBean.setTargetClass(SecurityContextHolder.class);
methodInvokingFactoryBean.setTargetMethod("setStrategyName");
methodInvokingFactoryBean.setArguments(TransmittableThreadLocalSecurityContextHolderStrategy.class.getName());
return methodInvokingFactoryBean;
}
}
@@ -0,0 +1,153 @@
package cn.iocoder.yudao.framework.security.config;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.security.core.authentication.MultiUserDetailsAuthenticationProvider;
import cn.iocoder.yudao.framework.security.core.filter.JWTAuthenticationTokenFilter;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.annotation.Resource;
import java.util.List;
/**
* 自定义的 Spring Security 配置适配器实现
*
* @author 芋道源码
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class YudaoWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Resource
private WebProperties webProperties;
@Resource
private MultiUserDetailsAuthenticationProvider authenticationProvider;
/**
* 认证失败处理类 Bean
*/
@Resource
private AuthenticationEntryPoint authenticationEntryPoint;
/**
* 权限不够处理器 Bean
*/
@Resource
private AccessDeniedHandler accessDeniedHandler;
/**
* 退出处理类 Bean
*/
@Resource
private LogoutSuccessHandler logoutSuccessHandler;
/**
* Token 认证过滤器 Bean
*/
@Resource
private JWTAuthenticationTokenFilter authenticationTokenFilter;
/**
* 自定义的权限映射 Bean 们
*
* @see #configure(HttpSecurity)
*/
@Resource
private List<AuthorizeRequestsCustomizer> authorizeRequestsCustomizers;
/**
* 由于 Spring Security 创建 AuthenticationManager 对象时,没声明 @Bean 注解,导致无法被注入
* 通过覆写父类的该方法,添加 @Bean 注解,解决该问题
*/
@Override
@Bean
@ConditionalOnMissingBean(AuthenticationManager.class)
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider);
}
/**
* 配置 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()
.headers().frameOptions().disable().and()
// 一堆自定义的 Spring Security 处理器
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler).and()
// 登出地址的配置
.logout().logoutSuccessHandler(logoutSuccessHandler).logoutRequestMatcher(request -> // 匹配多种用户类型的登出
StrUtil.equalsAny(request.getRequestURI(), buildAdminApi("/system/logout"),
buildAppApi("/member/logout")));
// 设置每个请求的权限
httpSecurity
// ①:全局共享规则
.authorizeRequests()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
.antMatchers(HttpMethod.GET, "/admin-ui/**").permitAll()
// 设置 App API 无需认证
.antMatchers(buildAppApi("/**")).permitAll()
.antMatchers("/common/**").permitAll()
// ②:每个项目的自定义规则
.and().authorizeRequests(registry -> // 下面,循环设置自定义规则
authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(registry)))
// ③:兜底规则,必须认证
.authorizeRequests()
.anyRequest().authenticated()
;
// 添加 JWT Filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
private String buildAdminApi(String url) {
return webProperties.getAdminApi().getPrefix() + url;
}
private String buildAppApi(String url) {
return webProperties.getAppApi().getPrefix() + url;
}
}
@@ -0,0 +1,127 @@
package cn.iocoder.yudao.framework.security.core;
import cn.hutool.core.map.MapUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
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.*;
/**
* 登录用户信息
*
* @author 芋道源码
*/
@Data
public class LoginUser implements UserDetails {
/**
* 用户编号
*/
private Long id;
/**
* 用户类型
*
* 关联 {@link UserTypeEnum}
*/
private Integer userType;
/**
* 最后更新时间
*/
private Date updateTime;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 状态
*/
private Integer status;
/**
* 租户编号
*/
private Long tenantId;
// ========== UserTypeEnum.ADMIN 独有字段 ==========
// TODO 芋艿:可以通过定义一个 Map<String, String> exts 的方式,去除管理员的字段。不过这样会导致系统比较复杂,所以暂时不去掉先;
/**
* 角色编号数组
*/
private Set<Long> roleIds;
/**
* 部门编号
*/
private Long deptId;
// ========== 上下文 ==========
/**
* 上下文字段,不进行持久化
*
* 1. 用于基于 LoginUser 维度的临时缓存
*/
@JsonIgnore
private Map<String, Object> context;
@Override
@JsonIgnore// 避免序列化
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
@JsonIgnore// 避免序列化
public boolean isEnabled() {
return CommonStatusEnum.ENABLE.getStatus().equals(status);
}
@Override
@JsonIgnore// 避免序列化
public Collection<? extends GrantedAuthority> getAuthorities() {
return new HashSet<>();
}
@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 判断
}
// ========== 上下文 ==========
public void setContext(String key, Object value) {
if (context == null) {
context = new HashMap<>();
}
context.put(key, value);
}
public <T> T getContext(String key, Class<T> type) {
return MapUtil.get(context, key, type);
}
}
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.framework.security.core.annotations;
import java.lang.annotation.*;
/**
* 声明用户需要登录
*
* 为什么不使用 {@link org.springframework.security.access.prepost.PreAuthorize} 注解,原因是不通过时,抛出的是认证不通过,而不是未登录
*
* @author 芋道源码
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuthenticated {
}
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.framework.security.core.aop;
import cn.iocoder.yudao.framework.security.core.annotations.PreAuthenticated;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@Aspect
@Slf4j
public class PreAuthenticatedAspect {
@Around("@annotation(preAuthenticated)")
public Object around(ProceedingJoinPoint joinPoint, PreAuthenticated preAuthenticated) throws Throwable {
if (SecurityFrameworkUtils.getLoginUser() == null) {
throw exception(UNAUTHORIZED);
}
return joinPoint.proceed();
}
}
@@ -0,0 +1,152 @@
package cn.iocoder.yudao.framework.security.core.authentication;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.service.SecurityAuthFrameworkService;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 支持多用户类型的 AuthenticationProvider 实现类
*
* 为什么不用 {@link org.springframework.security.authentication.ProviderManager} 呢?
* 原因是,需要每个用户类型实现对应的 {@link AuthenticationProvider} + authentication,略显麻烦。实际,也是可以实现的。
*
* 另外,额外支持 verifyTokenAndRefresh 校验令牌、logout 登出、mockLogin 模拟登陆等操作。
* 实际上,它就是 {@link SecurityAuthFrameworkService} 定义的三个接口。
* 因为需要支持多种类型,所以需要根据请求的 URL,判断出对应的用户类型,从而使用对应的 SecurityAuthFrameworkService 是吸纳
*
* @see cn.iocoder.yudao.framework.common.enums.UserTypeEnum
* @author 芋道源码
*/
@Slf4j
public class MultiUserDetailsAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
private final Map<UserTypeEnum, SecurityAuthFrameworkService> services = new HashMap<>();
private final WebProperties properties;
private final PasswordEncoder passwordEncoder;
public MultiUserDetailsAuthenticationProvider(List<SecurityAuthFrameworkService> serviceList,
WebProperties properties, PasswordEncoder passwordEncoder) {
serviceList.forEach(service -> services.put(service.getUserType(), service));
this.properties = properties;
this.passwordEncoder = passwordEncoder;
}
// ========== AuthenticationProvider 相关 ==========
@Override
protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
// 执行用户的加载
return selectService(authentication).loadUserByUsername(username);
}
private SecurityAuthFrameworkService selectService(UsernamePasswordAuthenticationToken authentication) {
// 第一步,获得用户类型
UserTypeEnum userType = getUserType(authentication);
// 第二步,获得 SecurityAuthFrameworkService
SecurityAuthFrameworkService service = services.get(userType);
Assert.notNull(service, "用户类型({}) 找不到 SecurityAuthFrameworkService 实现类", userType);
return service;
}
private UserTypeEnum getUserType(UsernamePasswordAuthenticationToken authentication) {
Assert.isInstanceOf(MultiUsernamePasswordAuthenticationToken.class, authentication);
MultiUsernamePasswordAuthenticationToken multiAuthentication = (MultiUsernamePasswordAuthenticationToken) authentication;
UserTypeEnum userType = multiAuthentication.getUserType();
Assert.notNull(userType, "用户类型不能为空");
return userType;
}
@Override // copy 自 DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
// 校验 credentials
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
// 校验 password
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
// ========== SecurityAuthFrameworkService 相关 ==========
/**
* 校验 token 的有效性,并获取用户信息
* 通过后,刷新 token 的过期时间
*
* @param request 请求
* @param token token
* @return 用户信息
*/
public LoginUser verifyTokenAndRefresh(HttpServletRequest request, String token) {
return selectService(request).verifyTokenAndRefresh(token);
}
/**
* 模拟指定用户编号的 LoginUser
*
* @param request 请求
* @param userId 用户编号
* @return 登录用户
*/
public LoginUser mockLogin(HttpServletRequest request, Long userId) {
return selectService(request).mockLogin(userId);
}
/**
* 基于 token 退出登录
*
* @param request 请求
* @param token token
*/
public void logout(HttpServletRequest request, String token) {
selectService(request).logout(token);
}
private SecurityAuthFrameworkService selectService(HttpServletRequest request) {
// 第一步,获得用户类型
UserTypeEnum userType = getUserType(request);
// 第二步,获得 SecurityAuthFrameworkService
SecurityAuthFrameworkService service = services.get(userType);
Assert.notNull(service, "URI({}) 用户类型({}) 找不到 SecurityAuthFrameworkService 实现类",
request.getRequestURI(), userType);
return service;
}
private UserTypeEnum getUserType(HttpServletRequest request) {
// log.error("URI:{}",request.getRequestURI());
if (request.getRequestURI().startsWith(properties.getAdminApi().getPrefix()) || request.getRequestURI().startsWith("/common/")) {
return UserTypeEnum.ADMIN;
}
if (request.getRequestURI().startsWith(properties.getAppApi().getPrefix())) {
return UserTypeEnum.MEMBER;
}
throw new IllegalArgumentException(StrUtil.format("URI({}) 找不到匹配的用户类型", request.getRequestURI()));
}
}
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.framework.security.core.authentication;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import lombok.Getter;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
/**
* 支持多用户的 UsernamePasswordAuthenticationToken 实现类
*
* @author 芋道源码
*/
@Getter
public class MultiUsernamePasswordAuthenticationToken extends UsernamePasswordAuthenticationToken {
/**
* 用户类型
*/
private UserTypeEnum userType;
public MultiUsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(principal, credentials);
}
public MultiUsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
public MultiUsernamePasswordAuthenticationToken(Object principal, Object credentials, UserTypeEnum userType) {
super(principal, credentials);
this.userType = userType;
}
public MultiUsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities, UserTypeEnum userType) {
super(principal, credentials, authorities);
this.userType = userType;
}
}
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.framework.security.core.context;
import com.alibaba.ttl.TransmittableThreadLocal;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.util.Assert;
/**
* 基于 TransmittableThreadLocal 实现的 Security Context 持有者策略
* 目的是,避免 @Async 等异步执行时,原生 ThreadLocal 的丢失问题
*
* @author 芋道源码
*/
public class TransmittableThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
/**
* 使用 TransmittableThreadLocal 作为上下文
*/
private static final ThreadLocal<SecurityContext> contextHolder = new TransmittableThreadLocal<>();
@Override
public void clearContext() {
contextHolder.remove();
}
@Override
public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();
if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
@Override
public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}
@Override
public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
@@ -0,0 +1,84 @@
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.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.security.config.SecurityProperties;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.authentication.MultiUserDetailsAuthenticationProvider;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.web.filter.OncePerRequestFilter;
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 芋道源码
*/
@RequiredArgsConstructor
public class JWTAuthenticationTokenFilter extends OncePerRequestFilter {
private final SecurityProperties securityProperties;
private final MultiUserDetailsAuthenticationProvider authenticationProvider;
private final 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 = authenticationProvider.verifyTokenAndRefresh(request, token);
// 模拟 Login 功能,方便日常开发调试
if (loginUser == null) {
loginUser = this.mockLoginUser(request, 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 request 请求
* @param token 模拟的 token,格式为 {@link SecurityProperties#getMockSecret()} + 用户编号
* @return 模拟的 LoginUser
*/
private LoginUser mockLoginUser(HttpServletRequest request, 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 authenticationProvider.mockLogin(request, userId);
}
}
@@ -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.common.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.FORBIDDEN;
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 芋道源码
*/
@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.getLoginUserId(), e);
// 返回 403
ServletUtils.writeJSON(response, CommonResult.error(FORBIDDEN));
}
}
@@ -0,0 +1,35 @@
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.common.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 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
*/
@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));
}
}
@@ -0,0 +1,40 @@
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.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.security.config.SecurityProperties;
import cn.iocoder.yudao.framework.security.core.authentication.MultiUserDetailsAuthenticationProvider;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import lombok.AllArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 自定义退出处理器
*
* @author ruoyi
*/
@AllArgsConstructor
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
private final SecurityProperties securityProperties;
private final MultiUserDetailsAuthenticationProvider authenticationProvider;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 执行退出
String token = SecurityFrameworkUtils.obtainAuthorization(request, securityProperties.getTokenHeader());
if (StrUtil.isNotBlank(token)) {
authenticationProvider.logout(request, token);
}
// 返回成功
ServletUtils.writeJSON(response, CommonResult.success(null));
}
}
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.framework.security.core.service;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import org.springframework.security.core.userdetails.UserDetailsService;
/**
* Security 框架 Auth Service 接口,定义不同用户类型的 {@link UserTypeEnum} 需要实现的方法
*
* @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);
/**
* 获得用户类型。每个用户类型,对应一个 SecurityAuthFrameworkService 实现类。
*
* @return 用户类型
*/
UserTypeEnum getUserType();
}
@@ -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);
}
@@ -0,0 +1,118 @@
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 认证信息
*/
public static Authentication getAuthentication() {
SecurityContext context = SecurityContextHolder.getContext();
if (context == null) {
return null;
}
return context.getAuthentication();
}
/**
* 获取当前用户
*
* @return 当前用户
*/
@Nullable
public static LoginUser getLoginUser() {
Authentication authentication = 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) {
// 创建 Authentication,并设置到上下文
Authentication authentication = buildAuthentication(loginUser, request);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号;
// 原因是,Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息
WebFrameworkUtils.setLoginUserId(request, loginUser.getId());
WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType());
}
private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) {
// 创建 UsernamePasswordAuthenticationToken 对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
return authenticationToken;
}
}
@@ -0,0 +1,7 @@
/**
* 基于 Spring Security 框架
* 实现安全认证功能
*
* @author 芋道源码
*/
package cn.iocoder.yudao.framework.security;
@@ -0,0 +1,3 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
cn.iocoder.yudao.framework.security.config.YudaoSecurityAutoConfiguration,\
cn.iocoder.yudao.framework.security.config.YudaoWebSecurityConfigurerAdapter
@@ -0,0 +1,2 @@
* 芋道 Spring Security 入门:<http://www.iocoder.cn/Spring-Boot/Spring-Security/?yudao>
* Spring Security 基本概念:<http://www.iocoder.cn/Fight/Spring-Security-4-1-0-Basic-concept-description/?yudao>