feat: [auth] PowerJobPermissionService

This commit is contained in:
tjq 2024-02-11 10:32:13 +08:00
parent 0caa854409
commit a1c12bf1c7
11 changed files with 447 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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<? extends DynamicPermission> dynamicPermissionPlugin() default EmptyDynamicPermission.class;
}

View File

@ -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<PowerJobUser> 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";
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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<String, Object> 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;
}
}

View File

@ -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);
}

View File

@ -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<UserRoleDO> userRoleList = Optional.ofNullable(userRoleRepository.findAllByUserId(user.getId())).orElse(Collections.emptyList());
Multimap<Long, Role> appId2Role = ArrayListMultimap.create();
Multimap<Long, Role> namespaceId2Role = ArrayListMultimap.create();
List<Role> 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<Role> appRoles = appId2Role.get(appId);
for (Role role : appRoles) {
if (role.getPermissions().contains(requiredPermission)) {
return true;
}
}
// 校验 namespace 穿透权限
Optional<AppInfoDO> 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<Role> 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<? extends DynamicPermission> 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();
}
}

View File

@ -22,6 +22,11 @@ public class AppInfoDO {
@GenericGenerator(name = "native", strategy = "native")
private Long id;
/**
* 命名空间ID外键关联
*/
private Long namespaceId;
private String appName;
/**
* 应用分组密码

View File

@ -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;
}

View File

@ -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<NamespaceDO, Long> {
Optional<NamespaceDO> findByCode(String code);
}