From a1c12bf1c7d4e55aa0252348b23e980823820b57 Mon Sep 17 00:00:00 2001 From: tjq Date: Sun, 11 Feb 2024 10:32:13 +0800 Subject: [PATCH] feat: [auth] PowerJobPermissionService --- .../tech/powerjob/server/auth/RoleScope.java | 9 ++ .../auth/interceptor/ApiPermission.java | 38 ++++++ .../interceptor/PowerJobAuthInterceptor.java | 93 +++++++++++++ .../interceptor/dp/DynamicPermission.java | 15 ++ .../dp/EmptyDynamicPermission.java | 18 +++ .../dp/ModifyOrCreateDynamicPermission.java | 45 ++++++ .../permission/PowerJobPermissionService.java | 25 ++++ .../PowerJobPermissionServiceImpl.java | 128 ++++++++++++++++++ .../persistence/remote/model/AppInfoDO.java | 5 + .../persistence/remote/model/NamespaceDO.java | 54 ++++++++ .../repository/NamespaceRepository.java | 17 +++ 11 files changed, 447 insertions(+) create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/ApiPermission.java create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/PowerJobAuthInterceptor.java create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/DynamicPermission.java create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/EmptyDynamicPermission.java create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/ModifyOrCreateDynamicPermission.java create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionService.java create mode 100644 powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionServiceImpl.java create mode 100644 powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/NamespaceDO.java create mode 100644 powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/repository/NamespaceRepository.java diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/RoleScope.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/RoleScope.java index 328d9acf..cec6f559 100644 --- a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/RoleScope.java +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/RoleScope.java @@ -28,4 +28,13 @@ public enum RoleScope { ; private final int v; + + public static RoleScope of(int vv) { + for (RoleScope rs : values()) { + if (vv == rs.v) { + return rs; + } + } + throw new IllegalArgumentException("unknown RoleScope: " + vv); + } } diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/ApiPermission.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/ApiPermission.java new file mode 100644 index 00000000..099447e5 --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/ApiPermission.java @@ -0,0 +1,38 @@ +package tech.powerjob.server.auth.interceptor; + +import tech.powerjob.server.auth.Permission; +import tech.powerjob.server.auth.interceptor.dp.DynamicPermission; +import tech.powerjob.server.auth.interceptor.dp.EmptyDynamicPermission; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * API 权限 + * + * @author tjq + * @since 2023/3/20 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiPermission { + /** + * API 名称 + * @return 空使用服务.方法名代替 + */ + String name() default ""; + + /** + * 需要的权限 + * @return 权限 + */ + Permission requiredPermission() default Permission.GLOBAL_SU; + + /** + * 固定权限不支持的场景,需要使用动态权限 + * @return 动态权限 + */ + Class dynamicPermissionPlugin() default EmptyDynamicPermission.class; +} diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/PowerJobAuthInterceptor.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/PowerJobAuthInterceptor.java new file mode 100644 index 00000000..d3c1f54c --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/PowerJobAuthInterceptor.java @@ -0,0 +1,93 @@ +package tech.powerjob.server.auth.interceptor; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import tech.powerjob.common.exception.PowerJobException; +import tech.powerjob.server.auth.LoginUserHolder; +import tech.powerjob.server.auth.PowerJobUser; +import tech.powerjob.server.auth.service.login.PowerJobLoginService; +import tech.powerjob.server.auth.service.permission.PowerJobPermissionService; +import tech.powerjob.server.common.Loggers; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.util.Optional; + +/** + * login auth and permission check + * + * @author tjq + * @since 2023/3/25 + */ +@Component +public class PowerJobAuthInterceptor implements HandlerInterceptor { + @Resource + private PowerJobLoginService powerJobLoginService; + @Resource + private PowerJobPermissionService powerJobPermissionService; + + @Override + public boolean preHandle(@NonNull HttpServletRequest request,@NonNull HttpServletResponse response,@NonNull Object handler) throws Exception { + if (!(handler instanceof HandlerMethod)) { + return true; + } + HandlerMethod handlerMethod = (HandlerMethod) handler; + final Method method = handlerMethod.getMethod(); + final ApiPermission apiPermissionAnno = method.getAnnotation(ApiPermission.class); + + // 无注解代表不需要权限,无需登陆直接访问 + if (apiPermissionAnno == null) { + return true; + } + + // 尝试直接解析登陆 + final Optional loginUserOpt = powerJobLoginService.ifLogin(request); + + // 未登录前先使用302重定向到登录页面 + if (!loginUserOpt.isPresent()) { + response.setStatus(302); + response.setHeader("location", request.getContextPath() + "/login"); + return false; + } + + // 登陆用户进行权限校验 + final PowerJobUser powerJobUser = loginUserOpt.get(); + + // 写入上下文 + LoginUserHolder.set(powerJobUser); + + final boolean hasPermission = powerJobPermissionService.hasPermission(request, handler, powerJobUser, apiPermissionAnno); + if (hasPermission) { + return true; + } + + final String resourceName = parseResourceName(apiPermissionAnno, handlerMethod); + Loggers.WEB.info("[PowerJobAuthInterceptor] user[{}] has no permission to access: {}", powerJobUser.getUsername(), resourceName); + + throw new PowerJobException("Permission denied!"); + } + + @Override + public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception ex) throws Exception { + LoginUserHolder.clean(); + } + + private static String parseResourceName(ApiPermission apiPermission, HandlerMethod handlerMethod) { + final String name = apiPermission.name(); + if (StringUtils.isNotEmpty(name)) { + return name; + } + try { + final String clzName = handlerMethod.getBean().getClass().getSimpleName(); + final String methodName = handlerMethod.getMethod().getName(); + return String.format("%s_%s", clzName, methodName); + } catch (Exception ignore) { + } + return "UNKNOWN"; + } +} diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/DynamicPermission.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/DynamicPermission.java new file mode 100644 index 00000000..baa803d7 --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/DynamicPermission.java @@ -0,0 +1,15 @@ +package tech.powerjob.server.auth.interceptor.dp; + +import tech.powerjob.server.auth.Permission; + +import javax.servlet.http.HttpServletRequest; + +/** + * 动态权限 + * + * @author tjq + * @since 2023/9/3 + */ +public interface DynamicPermission { + Permission calculate(HttpServletRequest request, Object handler); +} diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/EmptyDynamicPermission.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/EmptyDynamicPermission.java new file mode 100644 index 00000000..ccc3f2fd --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/EmptyDynamicPermission.java @@ -0,0 +1,18 @@ +package tech.powerjob.server.auth.interceptor.dp; + +import tech.powerjob.server.auth.Permission; + +import javax.servlet.http.HttpServletRequest; + +/** + * NotUseDynamicPermission + * + * @author tjq + * @since 2023/9/3 + */ +public class EmptyDynamicPermission implements DynamicPermission { + @Override + public Permission calculate(HttpServletRequest request, Object handler) { + return null; + } +} diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/ModifyOrCreateDynamicPermission.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/ModifyOrCreateDynamicPermission.java new file mode 100644 index 00000000..99afd84d --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/dp/ModifyOrCreateDynamicPermission.java @@ -0,0 +1,45 @@ +package tech.powerjob.server.auth.interceptor.dp; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StreamUtils; +import tech.powerjob.common.serialize.JsonUtils; +import tech.powerjob.server.auth.Permission; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * 针对 namespace 和 app 两大鉴权纬度,创建不需要任何权限,但任何修改操作都需要 WRITE 权限 + * 创建不需要权限,修改需要校验权限 + * + * @author tjq + * @since 2023/9/3 + */ +@Slf4j +public class ModifyOrCreateDynamicPermission implements DynamicPermission { + @Override + public Permission calculate(HttpServletRequest request, Object handler) { + + try { + //获取请求body + byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream()); + String body = new String(bodyBytes, request.getCharacterEncoding()); + + Map inputParams = JsonUtils.parseMap(body); + + Object id = inputParams.get("id"); + + // 创建,不需要权限 + if (id == null) { + return Permission.NONE; + } + + return Permission.WRITE; + } catch (Exception e) { + log.error("[ModifyOrCreateDynamicPermission] check permission failed, please fix the bug!!!"); + } + + // 异常情况先放行,不影响功能使用,后续修复 BUG + return Permission.NONE; + } +} diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionService.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionService.java new file mode 100644 index 00000000..3cbeb772 --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionService.java @@ -0,0 +1,25 @@ +package tech.powerjob.server.auth.service.permission; + +import tech.powerjob.server.auth.PowerJobUser; +import tech.powerjob.server.auth.interceptor.ApiPermission; + +import javax.servlet.http.HttpServletRequest; + +/** + * PowerJob 鉴权服务 + * + * @author tjq + * @since 2024/2/11 + */ +public interface PowerJobPermissionService { + + /** + * 判断用户是否有访问权限 + * @param request 上下文请求 + * @param handler hander + * @param user 用户 + * @param apiPermission 权限描述 + * @return true or false + */ + boolean hasPermission(HttpServletRequest request, Object handler, PowerJobUser user, ApiPermission apiPermission); +} diff --git a/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionServiceImpl.java b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionServiceImpl.java new file mode 100644 index 00000000..9753ace1 --- /dev/null +++ b/powerjob-server/powerjob-server-auth/src/main/java/tech/powerjob/server/auth/service/permission/PowerJobPermissionServiceImpl.java @@ -0,0 +1,128 @@ +package tech.powerjob.server.auth.service.permission; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.compress.utils.Lists; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.stereotype.Service; +import tech.powerjob.common.exception.ImpossibleException; +import tech.powerjob.server.auth.Permission; +import tech.powerjob.server.auth.PowerJobUser; +import tech.powerjob.server.auth.Role; +import tech.powerjob.server.auth.RoleScope; +import tech.powerjob.server.auth.interceptor.ApiPermission; +import tech.powerjob.server.auth.interceptor.dp.DynamicPermission; +import tech.powerjob.server.auth.interceptor.dp.EmptyDynamicPermission; +import tech.powerjob.server.persistence.remote.model.AppInfoDO; +import tech.powerjob.server.persistence.remote.model.UserRoleDO; +import tech.powerjob.server.persistence.remote.repository.AppInfoRepository; +import tech.powerjob.server.persistence.remote.repository.UserRoleRepository; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.*; + +/** + * PowerJobPermissionService + * + * @author tjq + * @since 2024/2/11 + */ +@Slf4j +@Service +public class PowerJobPermissionServiceImpl implements PowerJobPermissionService { + + @Resource + private AppInfoRepository appInfoRepository; + @Resource + private UserRoleRepository userRoleRepository; + + @Override + public boolean hasPermission(HttpServletRequest request, Object handler, PowerJobUser user, ApiPermission apiPermission) { + final List userRoleList = Optional.ofNullable(userRoleRepository.findAllByUserId(user.getId())).orElse(Collections.emptyList()); + + Multimap appId2Role = ArrayListMultimap.create(); + Multimap namespaceId2Role = ArrayListMultimap.create(); + + List globalRoles = Lists.newArrayList(); + + for (UserRoleDO userRole : userRoleList) { + final Role role = Role.of(userRole.getRole()); + RoleScope roleScope = RoleScope.of(userRole.getScope()); + + // 处理全局权限 + if (RoleScope.GLOBAL.equals(roleScope)) { + if (Role.ADMIN.equals(role)) { + return true; + } + globalRoles.add(role); + } + + if (Objects.equals(userRole.getScope(), RoleScope.NAMESPACE.getV())) { + namespaceId2Role.put(userRole.getTarget(), role); + } + if (Objects.equals(userRole.getScope(), RoleScope.APP.getV())) { + appId2Role.put(userRole.getTarget(), role); + } + } + + // 无超级管理员权限,校验普通权限 + final String appIdStr = request.getHeader("appId"); + if (StringUtils.isEmpty(appIdStr)) { + throw new IllegalArgumentException("can't find appId in header, please refresh and try again!"); + } + + Long appId = Long.valueOf(appIdStr); + + final Permission requiredPermission = parsePermission(request, handler, apiPermission); + if (requiredPermission == Permission.NONE) { + return true; + } + + final Collection appRoles = appId2Role.get(appId); + for (Role role : appRoles) { + if (role.getPermissions().contains(requiredPermission)) { + return true; + } + } + + // 校验 namespace 穿透权限 + Optional appInfoOpt = appInfoRepository.findById(appId); + if (!appInfoOpt.isPresent()) { + throw new IllegalArgumentException("can't find appInfo by appId in permission check: " + appId); + } + Long namespaceId = Optional.ofNullable(appInfoOpt.get().getNamespaceId()).orElse(-1L); + Collection namespaceRoles = namespaceId2Role.get(namespaceId); + for (Role role : namespaceRoles) { + if (role.getPermissions().contains(requiredPermission)) { + return true; + } + } + + // 检验全局穿透权限(按使用频率排列检测顺序,即 app -> namespace -> global) + for (Role role : globalRoles) { + if (role.getPermissions().contains(requiredPermission)) { + return true; + } + } + + return false; + } + + private static Permission parsePermission(HttpServletRequest request, Object handler, ApiPermission apiPermission) { + Class dynamicPermissionPlugin = apiPermission.dynamicPermissionPlugin(); + if (EmptyDynamicPermission.class.equals(dynamicPermissionPlugin)) { + return apiPermission.requiredPermission(); + } + try { + DynamicPermission dynamicPermission = dynamicPermissionPlugin.getDeclaredConstructor().newInstance(); + return dynamicPermission.calculate(request, handler); + } catch (Throwable t) { + log.error("[PowerJobAuthService] process dynamicPermissionPlugin failed!", t); + ExceptionUtils.rethrow(t); + } + throw new ImpossibleException(); + } +} diff --git a/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/AppInfoDO.java b/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/AppInfoDO.java index 7395af38..33165261 100644 --- a/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/AppInfoDO.java +++ b/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/AppInfoDO.java @@ -22,6 +22,11 @@ public class AppInfoDO { @GenericGenerator(name = "native", strategy = "native") private Long id; + /** + * 命名空间ID,外键关联 + */ + private Long namespaceId; + private String appName; /** * 应用分组密码 diff --git a/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/NamespaceDO.java b/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/NamespaceDO.java new file mode 100644 index 00000000..c1089926 --- /dev/null +++ b/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/model/NamespaceDO.java @@ -0,0 +1,54 @@ +package tech.powerjob.server.persistence.remote.model; + +import lombok.Data; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.util.Date; + +/** + * 命名空间,用于组织管理 App + * + * @author tjq + * @since 2023/9/3 + */ +@Data +@Entity +@Table(uniqueConstraints = {@UniqueConstraint(name = "uidx01_namespace", columnNames = {"code"})}) +public class NamespaceDO { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private Long id; + + /** + * 空间唯一标识 + */ + private String code; + + /** + * 空间名称,比如中文描述(XX部门XX空间) + */ + private String name; + + /** + * 鉴权 token,后续 OpenAPI 调用需要 + */ + private String token; + + private Integer status; + + /** + * 扩展字段 + */ + private String extra; + + private Date gmtCreate; + + private Date gmtModified; + + private String creator; + + private String modifier; +} diff --git a/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/repository/NamespaceRepository.java b/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/repository/NamespaceRepository.java new file mode 100644 index 00000000..845f8fca --- /dev/null +++ b/powerjob-server/powerjob-server-persistence/src/main/java/tech/powerjob/server/persistence/remote/repository/NamespaceRepository.java @@ -0,0 +1,17 @@ +package tech.powerjob.server.persistence.remote.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import tech.powerjob.server.persistence.remote.model.NamespaceDO; + +import java.util.Optional; + +/** + * 命名空间 + * + * @author tjq + * @since 2023/9/3 + */ +public interface NamespaceRepository extends JpaRepository { + + Optional findByCode(String code); +}