---初始化项目

This commit is contained in:
2025-09-19 16:14:08 +08:00
parent 902d3d7e3b
commit afee7c03ac
767 changed files with 75809 additions and 82 deletions

View File

@ -0,0 +1,58 @@
<?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>tech.powerjob</groupId>
<artifactId>powerjob-server</artifactId>
<version>5.1.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>powerjob-server-auth</artifactId>
<version>${project.parent.version}</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jjwt.version>0.11.5</jjwt.version>
<dingtalk.version>1.1.86</dingtalk.version>
</properties>
<dependencies>
<dependency>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-persistence</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dingtalk</artifactId>
<version>${dingtalk.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,48 @@
package tech.powerjob.server.auth;
/**
* LoginUserHolder
*
* @author tjq
* @since 2023/4/16
*/
public class LoginUserHolder {
private static final ThreadLocal<PowerJobUser> TL = new ThreadLocal<>();
public static PowerJobUser get() {
return TL.get();
}
public static void set(PowerJobUser powerJobUser) {
TL.set(powerJobUser);
}
public static void clean() {
TL.remove();
}
/**
* 获取用户名
* @return 存在则返回常规用户名,否则返回 unknown
*/
public static String getUserName() {
PowerJobUser powerJobUser = get();
if (powerJobUser != null) {
return powerJobUser.getUsername();
}
return "UNKNOWN";
}
/**
* 获取用户ID
* @return 存在则返回,否则返回 null
*/
public static Long getUserId() {
PowerJobUser powerJobUser = get();
if (powerJobUser != null) {
return powerJobUser.getId();
}
return null;
}
}

View File

@ -0,0 +1,40 @@
package tech.powerjob.server.auth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 权限
*
* @author tjq
* @since 2023/3/20
*/
@Getter
@AllArgsConstructor
public enum Permission {
/**
* 不需要权限
*/
NONE(1),
/**
* 读权限,查看控制台数据
*/
READ(10),
/**
* 写权限,新增/修改任务等
*/
WRITE(20),
/**
* 运维权限,比如任务的执行
*/
OPS(30),
/**
* 超级权限
*/
SU(100)
;
private int v;
}

View File

@ -0,0 +1,44 @@
package tech.powerjob.server.auth;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
/**
* PowerJob 的 登陆用户
*
* @author tjq
* @since 2023/3/20
*/
@Getter
@Setter
@ToString
public class PowerJobUser implements Serializable {
private Long id;
private String username;
/**
* 手机号
*/
private String phone;
/**
* 邮箱地址
*/
private String email;
/**
* webHook
*/
private String webHook;
/**
* 扩展字段
*/
private String extra;
/* ************** 以上为数据库字段 ************** */
private String jwtToken;
}

View File

@ -0,0 +1,53 @@
package tech.powerjob.server.auth;
import com.google.common.collect.Sets;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Set;
import static tech.powerjob.server.auth.Permission.*;
/**
* 角色
* PowerJob 采用 RBAC 实现权限,出于实际需求的考虑,不决定采用动态权限模型。因此 RBAC 中的角色和权限均在此处定义。
* 如果有自定义诉求,可以修改 Role 的定义
*
* @author tjq
* @since 2023/3/20
*/
@Getter
@AllArgsConstructor
public enum Role {
/**
* 观察者,默认只读权限
*/
OBSERVER(10, Sets.newHashSet(READ)),
/**
* 技术质量,读 + 操作权限
*/
QA(20, Sets.newHashSet(READ, OPS)),
/**
* 开发者,读 + 编辑 + 操作权限
*/
DEVELOPER(30, Sets.newHashSet(READ, WRITE, OPS)),
/**
* 管理员
*/
ADMIN(40, Sets.newHashSet(READ, WRITE, OPS, SU))
;
private final int v;
private final Set<Permission> permissions;
public static Role of(int vv) {
for (Role role : values()) {
if (vv == role.v) {
return role;
}
}
throw new IllegalArgumentException("unknown role: " + vv);
}
}

View File

@ -0,0 +1,40 @@
package tech.powerjob.server.auth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 权限范围
*
* @author tjq
* @since 2023/9/3
*/
@Getter
@AllArgsConstructor
public enum RoleScope {
/**
* NAMESPACE 权限
*/
NAMESPACE(1),
/**
* APP 级别权限
*/
APP(10),
/**
* 全局权限
*/
GLOBAL(666)
;
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,53 @@
package tech.powerjob.server.auth.common;
/**
* 常量
*
* @author tjq
* @since 2024/2/11
*/
public class AuthConstants {
/* ********** 账号体系唯一标识推荐开发者接入第三方登录体系时也使用4位编码便于前端统一做样式 ********** */
/**
* PowerJob自建账号体系
*/
public static final String ACCOUNT_TYPE_POWER_JOB = "PWJB";
/**
* 钉钉
*/
public static final String ACCOUNT_TYPE_DING = "DING";
/**
* 企业微信(预留,蹲一个 contributor
*/
public static final String ACCOUNT_TYPE_WX = "QYWX";
/**
* 飞书(预留,蹲一个 contributor +1
*/
public static final String ACCOUNT_LARK = "LARK";
public static final String PARAM_KEY_USERNAME = "username";
public static final String PARAM_KEY_PASSWORD = "password";
/**
* 前端参数-密码加密类型,官方版本出于成本未进行前后端传输的对称加密,接入方有需求可自行实现,此处定义加密协议字段
*/
public static final String PARAM_KEY_ENCRYPTION = "encryption";
/* ********** 账号体系 ********** */
/**
* JWT key
* 前端 header 默认首字母大写,保持一致方便处理
*/
public static final String OLD_JWT_NAME = "Power_jwt";
public static final String JWT_NAME = "PowerJwt";
/**
* 前端跳转到指定页面指令
*/
public static final String FE_REDIRECT_KEY = "FE-REDIRECT:";
public static final String TIPS_NO_PERMISSION_TO_SEE = "NO_PERMISSION_TO_SEE";
public static final Long GLOBAL_ADMIN_TARGET_ID = 1L;
}

View File

@ -0,0 +1,23 @@
package tech.powerjob.server.auth.common;
import lombok.Getter;
import tech.powerjob.common.enums.ErrorCodes;
import tech.powerjob.common.exception.PowerJobException;
/**
* 鉴权相关错误
*
* @author tjq
* @since 2024/2/10
*/
@Getter
public class PowerJobAuthException extends PowerJobException {
public PowerJobAuthException(ErrorCodes errorCode) {
this(errorCode, null);
}
public PowerJobAuthException(ErrorCodes errorCode, String extraMsg) {
super(errorCode, extraMsg);
}
}

View File

@ -0,0 +1,27 @@
package tech.powerjob.server.auth.common.utils;
import javax.servlet.http.HttpServletRequest;
import java.util.Objects;
/**
* 获取常用的请求头
*
* @author tjq
* @since 2025/1/21
*/
public class AuthHeaderUtils {
public static String fetchAppId(HttpServletRequest request) {
return HttpServletUtils.fetchFromHeader("AppId", request);
}
public static Long fetchAppIdL(HttpServletRequest request) {
return Long.valueOf(Objects.requireNonNull(HttpServletUtils.fetchFromHeader("AppId", request), "AppId from header is null!"));
}
public static String fetchNamespaceId(HttpServletRequest request) {
return HttpServletUtils.fetchFromHeader("NamespaceId", request);
}
public static Long fetchNamespaceIdL(HttpServletRequest request) {
return Long.valueOf(Objects.requireNonNull(HttpServletUtils.fetchFromHeader("NamespaceId", request), "NamespaceId from header is null!"));
}
}

View File

@ -0,0 +1,27 @@
package tech.powerjob.server.auth.common.utils;
import tech.powerjob.common.OmsConstant;
import javax.servlet.http.HttpServletRequest;
/**
* HttpServletUtils
*
* @author tjq
* @since 2024/2/12
*/
public class HttpServletUtils {
public static String fetchFromHeader(String key, HttpServletRequest httpServletRequest) {
// header、cookie 都能获取
String v = httpServletRequest.getHeader(key);
// 解决 window.localStorage.getItem 为 null 的问题
if (OmsConstant.NULL.equalsIgnoreCase(v) || "undefined".equalsIgnoreCase(v)) {
return null;
}
return v;
}
}

View File

@ -0,0 +1,45 @@
package tech.powerjob.server.auth.interceptor;
import tech.powerjob.server.auth.Permission;
import tech.powerjob.server.auth.RoleScope;
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 "";
RoleScope roleScope() default RoleScope.APP;
/**
* 需要的权限
* @return 权限
*/
Permission requiredPermission() default Permission.SU;
/**
* 固定权限不支持的场景,需要使用动态权限
* @return 动态权限
*/
Class<? extends DynamicPermissionPlugin> dynamicPermissionPlugin() default EmptyPlugin.class;
/**
* 新增场景,需要授权插件执行授权
* @return 授权插件
*/
Class<? extends GrantPermissionPlugin> grandPermissionPlugin() default EmptyPlugin.class;
}

View File

@ -0,0 +1,65 @@
package tech.powerjob.server.auth.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* ApiPermission 切面
* 主要用于执行授权插件,完成创建后授权
*
* @author tjq
* @since 2024/2/11
*/
@Slf4j
@Aspect
@Component
public class ApiPermissionAspect {
@Pointcut("@annotation(ApiPermission)")
public void apiPermissionPointcut() {
// 定义切入点
}
/**
* 后置返回
* 如果第一个参数为JoinPoint则第二个参数为返回值的信息
* 如果第一个参数不为JoinPoint则第一个参数为returning中对应的参数
* returning限定了只有目标方法返回值与通知方法参数类型匹配时才能执行后置返回通知否则不执行
* 参数为Object类型将匹配任何目标返回值
* After注解标注的方法会在目标方法执行后运行无论目标方法是正常完成还是抛出异常。它相当于finally块因为它总是执行所以适用于释放资源等清理活动。@After注解不能访问目标方法的返回值。
* AfterReturning注解标注的方法仅在目标方法成功执行后即正常返回运行。它可以访问目标方法的返回值。使用@AfterReturning可以在方法正常返回后执行一些逻辑比如对返回值进行处理或验证。
*/
@AfterReturning(value = "apiPermissionPointcut()", returning = "result")
public void doAfterReturningAdvice1(JoinPoint joinPoint, Object result) {
// 入参
Object[] args = joinPoint.getArgs();
// 获取目标方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
ApiPermission annotationAnno = AnnotationUtils.getAnnotation(method, ApiPermission.class);
assert annotationAnno != null;
Class<? extends GrantPermissionPlugin> grandPermissionPluginClz = annotationAnno.grandPermissionPlugin();
try {
GrantPermissionPlugin grandPermissionPlugin = grandPermissionPluginClz.getDeclaredConstructor().newInstance();
grandPermissionPlugin.grant(args, result, method, joinPoint.getTarget());
} catch (Exception e) {
log.error("[ApiPermissionAspect] process ApiPermission grant failed", e);
ExceptionUtils.rethrow(e);
}
}
}

View File

@ -0,0 +1,15 @@
package tech.powerjob.server.auth.interceptor;
import tech.powerjob.server.auth.Permission;
import javax.servlet.http.HttpServletRequest;
/**
* 动态权限
*
* @author tjq
* @since 2023/9/3
*/
public interface DynamicPermissionPlugin {
Permission calculate(HttpServletRequest request, Object handler);
}

View File

@ -0,0 +1,24 @@
package tech.powerjob.server.auth.interceptor;
import tech.powerjob.server.auth.Permission;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
/**
* 空
*
* @author tjq
* @since 2024/2/12
*/
public class EmptyPlugin implements DynamicPermissionPlugin, GrantPermissionPlugin {
@Override
public Permission calculate(HttpServletRequest request, Object handler) {
return null;
}
@Override
public void grant(Object[] args, Object result, Method method, Object originBean) {
}
}

View File

@ -0,0 +1,21 @@
package tech.powerjob.server.auth.interceptor;
import java.lang.reflect.Method;
/**
* 授予权限插件
*
* @author tjq
* @since 2024/2/11
*/
public interface GrantPermissionPlugin {
/**
* 授权
* @param args 入参
* @param result 响应
* @param method 被调用方法
* @param originBean 原始对象
*/
void grant(Object[] args, Object result, Method method, Object originBean);
}

View File

@ -0,0 +1,135 @@
package tech.powerjob.server.auth.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
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.ImpossibleException;
import tech.powerjob.common.exception.PowerJobException;
import tech.powerjob.server.auth.LoginUserHolder;
import tech.powerjob.server.auth.Permission;
import tech.powerjob.server.auth.PowerJobUser;
import tech.powerjob.server.auth.RoleScope;
import tech.powerjob.common.enums.ErrorCodes;
import tech.powerjob.server.auth.common.PowerJobAuthException;
import tech.powerjob.server.auth.common.utils.AuthHeaderUtils;
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
*/
@Slf4j
@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);
// 未登录直接报错,返回固定状态码,前端拦截后跳转到登录页
if (!loginUserOpt.isPresent()) {
throw new PowerJobAuthException(ErrorCodes.USER_NOT_LOGIN);
}
// 登陆用户进行权限校验
final PowerJobUser powerJobUser = loginUserOpt.get();
// 写入上下文
LoginUserHolder.set(powerJobUser);
Permission requiredPermission = parsePermission(request, handler, apiPermissionAnno);
RoleScope roleScope = apiPermissionAnno.roleScope();
Long targetId = null;
if (RoleScope.NAMESPACE.equals(roleScope)) {
final String namespaceIdStr = AuthHeaderUtils.fetchNamespaceId(request);
if (StringUtils.isNotEmpty(namespaceIdStr)) {
targetId = Long.valueOf(namespaceIdStr);
}
}
if (RoleScope.APP.equals(roleScope)) {
final String appIdStr = AuthHeaderUtils.fetchAppId(request);
if (StringUtils.isNotEmpty(appIdStr)) {
targetId = Long.valueOf(appIdStr);
}
}
final boolean hasPermission = powerJobPermissionService.hasPermission(powerJobUser.getId(), roleScope, targetId, requiredPermission);
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";
}
private static Permission parsePermission(HttpServletRequest request, Object handler, ApiPermission apiPermission) {
Class<? extends DynamicPermissionPlugin> dynamicPermissionPlugin = apiPermission.dynamicPermissionPlugin();
if (EmptyPlugin.class.equals(dynamicPermissionPlugin)) {
return apiPermission.requiredPermission();
}
try {
DynamicPermissionPlugin 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

@ -0,0 +1,16 @@
package tech.powerjob.server.auth.jwt;
import java.util.Map;
/**
* JWT 服务
*
* @author tjq
* @since 2023/3/20
*/
public interface JwtService {
String build(Map<String, Object> body, String extraSk);
ParseResult parse(String jwt, String extraSk);
}

View File

@ -0,0 +1,35 @@
package tech.powerjob.server.auth.jwt;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Map;
/**
* 解析结果
*
* @author tjq
* @since 2024/8/11
*/
@Data
@Accessors(chain = true)
public class ParseResult implements Serializable {
/**
* 解析状态
*/
private Status status;
/**
* 解析结果
*/
private Map<String, Object> result;
private String msg;
public enum Status {
SUCCESS,
EXPIRED,
FAILED
}
}

View File

@ -0,0 +1,13 @@
package tech.powerjob.server.auth.jwt;
/**
* JWT 安全性的核心
* 对安全性有要求的接入方,可以自行重新该方法,自定义自己的安全 token 生成策略
*
* @author tjq
* @since 2023/3/20
*/
public interface SecretProvider {
String fetchSecretKey();
}

View File

@ -0,0 +1,47 @@
package tech.powerjob.server.auth.jwt.impl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import tech.powerjob.server.auth.jwt.SecretProvider;
import tech.powerjob.common.utils.DigestUtils;
import javax.annotation.Resource;
/**
* PowerJob 默认实现
*
* @author tjq
* @since 2023/3/20
*/
@Slf4j
@Component
public class DefaultSecretProvider implements SecretProvider {
@Resource
private Environment environment;
private static final String PROPERTY_KEY = "spring.datasource.core.jdbc-url";
@Override
public String fetchSecretKey() {
// 考虑到大部分用户都是开箱即用此处还是提供一个相对安全的默认实现。JDBC URL 部署时必会改skey 不固定,更安全
try {
String propertyValue = environment.getProperty(PROPERTY_KEY);
if (StringUtils.isNotEmpty(propertyValue)) {
String md5 = DigestUtils.md5(propertyValue);
log.debug("[DefaultSecretProvider] propertyValue: {} ==> md5: {}", propertyValue, md5);
if (StringUtils.isNotEmpty(md5)) {
return md5;
}
}
} catch (Exception ignore) {
}
return "ZQQZJ";
}
}

View File

@ -0,0 +1,104 @@
package tech.powerjob.server.auth.jwt.impl;
import com.google.common.collect.Maps;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tech.powerjob.server.auth.jwt.JwtService;
import tech.powerjob.server.auth.jwt.ParseResult;
import tech.powerjob.server.auth.jwt.SecretProvider;
import javax.annotation.Resource;
import java.security.Key;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
/**
* JWT 默认实现
*
* @author tjq
* @since 2023/3/20
*/
@Slf4j
@Service
public class JwtServiceImpl implements JwtService {
@Resource
private SecretProvider secretProvider;
/**
* JWT 客户端过期时间
*/
@Value("${oms.auth.security.jwt.expire-seconds:604800}")
private int jwtExpireTime;
/**
* <a href="https://music.163.com/#/song?id=167975">GoodSong</a>
*/
private static final String BASE_SECURITY =
"CengMengXiangZhangJianZouTianYa" +
"KanYiKanShiJieDeFanHua" +
"NianShaoDeXinZongYouXieQingKuang" +
"RuJinWoSiHaiWeiJia"
;
@Override
public String build(Map<String, Object> body, String extraSk) {
final String secret = fetchSk(extraSk);
return innerBuild(secret, jwtExpireTime, body);
}
static String innerBuild(String secret, int expireSeconds, Map<String, Object> body) {
JwtBuilder jwtBuilder = Jwts.builder()
.setHeaderParam("typ", "JWT")
.addClaims(body)
.setSubject("PowerJob")
.setExpiration(new Date(System.currentTimeMillis() + 1000L * expireSeconds))
.setId(UUID.randomUUID().toString())
.signWith(genSecretKey(secret), SignatureAlgorithm.HS256);
return jwtBuilder.compact();
}
@Override
public ParseResult parse(String jwt, String extraSk) {
try {
Map<String, Object> parseResult = innerParse(fetchSk(extraSk), jwt);
return new ParseResult().setStatus(ParseResult.Status.SUCCESS).setResult(parseResult);
} catch (ExpiredJwtException expiredJwtException) {
return new ParseResult().setStatus(ParseResult.Status.EXPIRED).setMsg(expiredJwtException.getMessage());
} catch (Exception e) {
log.warn("[JwtService] parse jwt[{}] with extraSk[{}] failed", jwt, extraSk, e);
return new ParseResult().setStatus(ParseResult.Status.FAILED).setMsg(ExceptionUtils.getMessage(e));
}
}
private String fetchSk(String extraSk) {
if (StringUtils.isEmpty(extraSk)) {
return secretProvider.fetchSecretKey();
}
return secretProvider.fetchSecretKey().concat(extraSk);
}
static Map<String, Object> innerParse(String secret, String jwtStr) {
final Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(genSecretKey(secret))
.build()
.parseClaimsJws(jwtStr);
Map<String, Object> ret = Maps.newHashMap();
ret.putAll(claimsJws.getBody());
return ret;
}
private static Key genSecretKey(String secret) {
byte[] keyBytes = Decoders.BASE64.decode(BASE_SECURITY.concat(secret));
return Keys.hmacShaKeyFor(keyBytes);
}
}

View File

@ -0,0 +1,30 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 登录类型描述
*
* @author tjq
* @since 2024/2/10
*/
@Data
@Accessors(chain = true)
public class LoginTypeInfo implements Serializable {
/**
* 登录类型,唯一标识
*/
private String type;
/**
* 描述名称,前端展示用
*/
private String name;
/**
* 展示用的 ICON
*/
private String iconUrl;
}

View File

@ -0,0 +1,24 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import lombok.experimental.Accessors;
import javax.servlet.http.HttpServletRequest;
/**
* 第三方登录请求
*
* @author tjq
* @since 2024/2/10
*/
@Data
@Accessors(chain = true)
public class ThirdPartyLoginRequest {
/**
* 原始参数给第三方登录方式一个服务端和前端交互的数据通道。PowerJob 本身不感知其中的内容
*/
private String originParams;
private transient HttpServletRequest httpServletRequest;
}

View File

@ -0,0 +1,42 @@
package tech.powerjob.server.auth.login;
import javax.servlet.http.HttpServletRequest;
/**
* 第三方登录服务
*
* @author tjq
* @since 2024/2/10
*/
public interface ThirdPartyLoginService {
/**
* 登陆服务的类型
* @return 登陆服务类型,比如 PowerJob / DingTalk
*/
LoginTypeInfo loginType();
/**
* 生成登陆的重定向 URL
* @param httpServletRequest http请求
* @return 重定向地址
*/
String generateLoginUrl(HttpServletRequest httpServletRequest);
/**
* 执行第三方登录
* @param loginRequest 上下文
* @return 登录地址
*/
ThirdPartyUser login(ThirdPartyLoginRequest loginRequest);
/**
* JWT 登录的回调校验
* @param username 用户名称
* @param tokenLoginVerifyInfo 二次校验信息
* @return 是否通过
*/
default boolean tokenLoginVerify(String username, TokenLoginVerifyInfo tokenLoginVerifyInfo) {
return true;
}
}

View File

@ -0,0 +1,47 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 第三方用户
*
* @author tjq
* @since 2024/2/10
*/
@Data
@Accessors(chain = true)
public class ThirdPartyUser {
/**
* 用户的唯一标识,用于关联到 PowerJob 的 username
*/
private String username;
/**
* JWT 登录的二次校验配置
* 可空,空则代表放弃二次校验(会出现第三方登录改了密码当 PowerJob JWT 登录依然可用的情况)
*/
private TokenLoginVerifyInfo tokenLoginVerifyInfo;
/* ******** 以下全部选填即可,只是方便数据同步,后续都可以去 PowerJob 控制台更改 ******** */
/**
* 用户昵称
*/
private String nick;
/**
* 手机号
*/
private String phone;
/**
* 邮箱地址
*/
private String email;
/**
* web 回调地址
*/
private String webHook;
/**
* 扩展字段
*/
private String extra;
}

View File

@ -0,0 +1,31 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import java.io.Serializable;
/**
* JWT 登录时的校验信息
*
* @author tjq
* @since 2024/2/16
*/
@Data
public class TokenLoginVerifyInfo implements Serializable {
/**
* 加密 token 部分,比如密码的 md5会直接写入 JWT 下发给前端
* 如果需要使用 JWT 二次校验,则该参数必须存在
*/
private String encryptedToken;
/**
* 补充信息,用于二次校验
*/
private String additionalInfo;
/**
* 依然是预留字段,第三方实现自用即可
*/
private String extra;
}

View File

@ -0,0 +1,142 @@
package tech.powerjob.server.auth.login.impl;
import com.aliyun.dingtalkcontact_1_0.models.GetUserHeaders;
import com.aliyun.dingtalkcontact_1_0.models.GetUserResponseBody;
import com.aliyun.dingtalkoauth2_1_0.models.GetUserTokenRequest;
import com.aliyun.dingtalkoauth2_1_0.models.GetUserTokenResponse;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tech.powerjob.common.exception.PowerJobException;
import tech.powerjob.server.auth.common.AuthConstants;
import tech.powerjob.server.auth.login.*;
import tech.powerjob.server.common.Loggers;
import javax.servlet.http.HttpServletRequest;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* <a href="https://open.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information">钉钉账号体系登录第三方网站</a>
* PowerJob 官方支持钉钉账号体系登录原因:
* 1. 钉钉作为当下用户体量最大的企业级办公软件,覆盖率足够高,提供钉钉支持能让更多开发者开箱即用
* 2. 钉钉的 API 设计和 PowerJob 设想一致,算是个最佳实践,其他企业内部的账号体系可参考这套流程进行接入
* - PowerJob 重定向到第三方账号体系登陆页 -> 第三方完成登陆 -> 跳转回调 PowerJob auth 接口 -> PowerJob 解析回调登陆信息,完整用户关联
*
* @author tjq
* @since 2023/3/26
*/
@Service
public class DingTalkLoginService implements ThirdPartyLoginService {
/*
配置示例
oms.auth.dingtalk.appkey=dinggzqqzqqzqqzqq
oms.auth.dingtalk.appSecret=iY-FS8mzqqzqq_xEizqqzqqzqqzqqzqqzqqYEbkZOal
oms.auth.dingtalk.callbackUrl=http://localhost:7700
*/
/**
* 钉钉应用 AppKey
*/
@Value("${oms.auth.dingtalk.appkey:#{null}}")
private String dingTalkAppKey;
/**
* 钉钉应用 AppSecret
*/
@Value("${oms.auth.dingtalk.appSecret:#{null}}")
private String dingTalkAppSecret;
/**
* 回调地址powerjob 前端控制台地址,即 powerjob-console 地址
* 比如本地调试时为 <a href="http://localhost:7700">LocalDemoCallbackUrl</a>
* 部署后则为 <a href="http://try.powerjob.tech">demoCallBackUrl</a>
*/
@Value("${oms.auth.dingtalk.callbackUrl:#{null}}")
private String dingTalkCallbackUrl;
@Override
public LoginTypeInfo loginType() {
return new LoginTypeInfo()
.setType(AuthConstants.ACCOUNT_TYPE_DING)
.setName("DingTalk")
;
}
@Override
@SneakyThrows
public String generateLoginUrl(HttpServletRequest httpServletRequest) {
if (StringUtils.isAnyEmpty(dingTalkAppKey, dingTalkAppSecret, dingTalkCallbackUrl)) {
throw new IllegalArgumentException("please config 'oms.auth.dingtalk.appkey', 'oms.auth.dingtalk.appSecret' and 'oms.auth.dingtalk.callbackUrl' in properties!");
}
String urlString = URLEncoder.encode(dingTalkCallbackUrl, StandardCharsets.UTF_8.name());
String url = "https://login.dingtalk.com/oauth2/auth?" +
"redirect_uri=" + urlString +
"&response_type=code" +
"&client_id=" + dingTalkAppKey +
"&scope=openid" +
"&state=" + AuthConstants.ACCOUNT_TYPE_DING +
"&prompt=consent";
Loggers.WEB.info("[DingTalkBizLoginService] login url: {}", url);
return url;
}
@Override
@SneakyThrows
public ThirdPartyUser login(ThirdPartyLoginRequest loginRequest) {
try {
com.aliyun.dingtalkoauth2_1_0.Client client = authClient();
GetUserTokenRequest getUserTokenRequest = new GetUserTokenRequest()
//应用基础信息-应用信息的AppKey,请务必替换为开发的应用AppKey
.setClientId(dingTalkAppKey)
//应用基础信息-应用信息的AppSecret,请务必替换为开发的应用AppSecret
.setClientSecret(dingTalkAppSecret)
.setCode(loginRequest.getHttpServletRequest().getParameter("authCode"))
.setGrantType("authorization_code");
GetUserTokenResponse getUserTokenResponse = client.getUserToken(getUserTokenRequest);
//获取用户个人 token
String accessToken = getUserTokenResponse.getBody().getAccessToken();
// 查询钉钉用户
final GetUserResponseBody dingUser = getUserinfo(accessToken);
// 将钉钉用户的唯一ID 和 PowerJob 账户体系的唯一键 username 关联
if (dingUser != null) {
ThirdPartyUser bizUser = new ThirdPartyUser();
bizUser.setUsername(dingUser.getUnionId());
bizUser.setNick(dingUser.getNick());
bizUser.setPhone(dingUser.getMobile());
bizUser.setEmail(dingUser.getEmail());
return bizUser;
}
} catch (Exception e) {
Loggers.WEB.error("[DingTalkBizLoginService] login by dingTalk failed!", e);
throw e;
}
throw new PowerJobException("login from dingTalk failed!");
}
/* 以下代码均拷自钉钉官网示例 */
private static com.aliyun.dingtalkoauth2_1_0.Client authClient() throws Exception {
Config config = new Config();
config.protocol = "https";
config.regionId = "central";
return new com.aliyun.dingtalkoauth2_1_0.Client(config);
}
private static com.aliyun.dingtalkcontact_1_0.Client contactClient() throws Exception {
Config config = new Config();
config.protocol = "https";
config.regionId = "central";
return new com.aliyun.dingtalkcontact_1_0.Client(config);
}
private GetUserResponseBody getUserinfo(String accessToken) throws Exception {
com.aliyun.dingtalkcontact_1_0.Client client = contactClient();
GetUserHeaders getUserHeaders = new GetUserHeaders();
getUserHeaders.xAcsDingtalkAccessToken = accessToken;
//获取用户个人信息如需获取当前授权人的信息unionId参数必须传me
return client.getUserWithOptions("me", getUserHeaders, new RuntimeOptions()).getBody();
}
}

View File

@ -0,0 +1,121 @@
package tech.powerjob.server.auth.login.impl;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import tech.powerjob.common.exception.PowerJobException;
import tech.powerjob.common.serialize.JsonUtils;
import tech.powerjob.server.auth.common.AuthConstants;
import tech.powerjob.common.enums.ErrorCodes;
import tech.powerjob.server.auth.common.PowerJobAuthException;
import tech.powerjob.server.auth.login.*;
import tech.powerjob.server.common.Loggers;
import tech.powerjob.common.utils.DigestUtils;
import tech.powerjob.server.persistence.remote.model.PwjbUserInfoDO;
import tech.powerjob.server.persistence.remote.repository.PwjbUserInfoRepository;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Optional;
/**
* PowerJob 自带的登陆服务
* 和应用主框架无关,依然属于第三方登录体系
*
* @author tjq
* @since 2023/3/20
*/
@Service
public class PwjbAccountLoginService implements ThirdPartyLoginService {
@Resource
private PwjbUserInfoRepository pwjbUserInfoRepository;
@Override
public LoginTypeInfo loginType() {
return new LoginTypeInfo()
.setType(AuthConstants.ACCOUNT_TYPE_POWER_JOB)
.setName("PowerJob Account")
;
}
@Override
public String generateLoginUrl(HttpServletRequest httpServletRequest) {
// 前端实现跳转,服务端返回特殊指令
return AuthConstants.FE_REDIRECT_KEY.concat("powerjobLogin");
}
@Override
public ThirdPartyUser login(ThirdPartyLoginRequest loginRequest) {
final String loginInfo = loginRequest.getOriginParams();
if (StringUtils.isEmpty(loginInfo)) {
throw new IllegalArgumentException("can't find login Info");
}
Map<String, Object> loginInfoMap = JsonUtils.parseMap(loginInfo);
final String username = MapUtils.getString(loginInfoMap, AuthConstants.PARAM_KEY_USERNAME);
final String password = MapUtils.getString(loginInfoMap, AuthConstants.PARAM_KEY_PASSWORD);
final String encryption = MapUtils.getString(loginInfoMap, AuthConstants.PARAM_KEY_ENCRYPTION);
Loggers.WEB.debug("[PowerJobLoginService] encryption: {}", encryption);
if (StringUtils.isAnyEmpty(username, password)) {
Loggers.WEB.debug("[PowerJobLoginService] username or password is empty, login failed!");
throw new PowerJobAuthException(ErrorCodes.INVALID_REQUEST);
}
final Optional<PwjbUserInfoDO> userInfoOpt = pwjbUserInfoRepository.findByUsername(username);
if (!userInfoOpt.isPresent()) {
Loggers.WEB.debug("[PowerJobLoginService] can't find user by username: {}", username);
throw new PowerJobAuthException(ErrorCodes.USER_NOT_EXIST);
}
final PwjbUserInfoDO dbUser = userInfoOpt.get();
if (DigestUtils.rePassword(password, username).equals(dbUser.getPassword())) {
ThirdPartyUser bizUser = new ThirdPartyUser();
bizUser.setUsername(username);
// 回填第一次创建的信息
String extra = dbUser.getExtra();
if (StringUtils.isNotEmpty(extra)) {
ThirdPartyUser material = JsonUtils.parseObjectIgnoreException(extra, ThirdPartyUser.class);
if (material != null) {
bizUser.setEmail(material.getEmail());
bizUser.setNick(material.getNick());
bizUser.setPhone(material.getPhone());
bizUser.setWebHook(material.getWebHook());
}
}
// 下发加密的密码作为 JWT 的一部分,方便处理改密码后失效的场景
TokenLoginVerifyInfo tokenLoginVerifyInfo = new TokenLoginVerifyInfo();
tokenLoginVerifyInfo.setEncryptedToken(dbUser.getPassword());
bizUser.setTokenLoginVerifyInfo(tokenLoginVerifyInfo);
return bizUser;
}
Loggers.WEB.debug("[PowerJobLoginService] user[{}]'s password is incorrect, login failed!", username);
throw new PowerJobException("password is incorrect");
}
@Override
public boolean tokenLoginVerify(String username, TokenLoginVerifyInfo tokenLoginVerifyInfo) {
if (tokenLoginVerifyInfo == null) {
return false;
}
final Optional<PwjbUserInfoDO> userInfoOpt = pwjbUserInfoRepository.findByUsername(username);
if (userInfoOpt.isPresent()) {
String dbPassword = userInfoOpt.get().getPassword();
return StringUtils.equals(dbPassword, tokenLoginVerifyInfo.getEncryptedToken());
}
return false;
}
}

View File

@ -0,0 +1,32 @@
package tech.powerjob.server.auth.service.login;
import lombok.Data;
import lombok.experimental.Accessors;
import javax.servlet.http.HttpServletRequest;
/**
* 执行登录的请求
*
* @author tjq
* @since 2024/2/10
*/
@Data
@Accessors(chain = true)
public class LoginRequest {
/**
* 登录类型
*/
private String loginType;
/**
* 原始参数给第三方登录方式一个服务端和前端交互的数据通道。PowerJob 本身不感知其中的内容
*/
private String originParams;
/**
* http原始请求第三方回调参数传递类型无法枚举直接传递 HttpServletRequest 满足扩展性要求
*/
private transient HttpServletRequest httpServletRequest;
}

View File

@ -0,0 +1,48 @@
package tech.powerjob.server.auth.service.login;
import tech.powerjob.server.auth.PowerJobUser;
import tech.powerjob.server.auth.common.PowerJobAuthException;
import tech.powerjob.server.auth.login.LoginTypeInfo;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Optional;
/**
* PowerJob 登录服务
*
* @author tjq
* @since 2024/2/10
*/
public interface PowerJobLoginService {
/**
* 获取全部可登录的类型
* @return 全部可登录类型
*/
List<LoginTypeInfo> fetchSupportLoginTypes();
/**
* 获取第三方登录链接
* @param loginType 登录类型
* @param httpServletRequest http请求
* @return 重定向地址
*/
String fetchThirdPartyLoginUrl(String loginType, HttpServletRequest httpServletRequest);
/**
* 执行真正的登录请求,底层调用第三方登录服务完成登录
* @param loginRequest 登录请求
* @return 登录完成的 PowerJobUser
* @throws PowerJobAuthException 鉴权失败抛出异常
*/
PowerJobUser doLogin(LoginRequest loginRequest) throws PowerJobAuthException;
/**
* 从 JWT 信息中解析用户登录信息
* @param httpServletRequest httpServletRequest
* @return PowerJob 用户
*/
Optional<PowerJobUser> ifLogin(HttpServletRequest httpServletRequest);
}

View File

@ -0,0 +1,255 @@
package tech.powerjob.server.auth.service.login.impl;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import lombok.Data;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.powerjob.common.serialize.JsonUtils;
import tech.powerjob.server.auth.LoginUserHolder;
import tech.powerjob.server.auth.PowerJobUser;
import tech.powerjob.server.auth.common.AuthConstants;
import tech.powerjob.common.enums.ErrorCodes;
import tech.powerjob.server.auth.common.PowerJobAuthException;
import tech.powerjob.server.auth.common.utils.HttpServletUtils;
import tech.powerjob.server.auth.jwt.JwtService;
import tech.powerjob.server.auth.login.*;
import tech.powerjob.server.auth.service.login.LoginRequest;
import tech.powerjob.server.auth.service.login.PowerJobLoginService;
import tech.powerjob.server.common.Loggers;
import tech.powerjob.common.enums.SwitchableStatus;
import tech.powerjob.server.persistence.remote.model.UserInfoDO;
import tech.powerjob.server.persistence.remote.repository.UserInfoRepository;
import javax.servlet.http.HttpServletRequest;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* PowerJob 登录服务
*
* @author tjq
* @since 2024/2/10
*/
@Slf4j
@Service
public class PowerJobLoginServiceImpl implements PowerJobLoginService {
private final JwtService jwtService;
private final UserInfoRepository userInfoRepository;
private final Map<String, ThirdPartyLoginService> code2ThirdPartyLoginService;
@Autowired
public PowerJobLoginServiceImpl(JwtService jwtService, UserInfoRepository userInfoRepository, List<ThirdPartyLoginService> thirdPartyLoginServices) {
this.jwtService = jwtService;
this.userInfoRepository = userInfoRepository;
code2ThirdPartyLoginService = Maps.newHashMap();
thirdPartyLoginServices.forEach(s -> {
code2ThirdPartyLoginService.put(s.loginType().getType(), s);
log.info("[PowerJobLoginService] register ThirdPartyLoginService: {}", s.loginType());
});
}
@Override
public List<LoginTypeInfo> fetchSupportLoginTypes() {
return Lists.newArrayList(code2ThirdPartyLoginService.values()).stream().map(ThirdPartyLoginService::loginType).collect(Collectors.toList());
}
@Override
public String fetchThirdPartyLoginUrl(String type, HttpServletRequest httpServletRequest) {
final ThirdPartyLoginService thirdPartyLoginService = fetchBizLoginService(type);
return thirdPartyLoginService.generateLoginUrl(httpServletRequest);
}
@Override
public PowerJobUser doLogin(LoginRequest loginRequest) throws PowerJobAuthException {
final String loginType = loginRequest.getLoginType();
final ThirdPartyLoginService thirdPartyLoginService = fetchBizLoginService(loginType);
ThirdPartyLoginRequest thirdPartyLoginRequest = new ThirdPartyLoginRequest()
.setOriginParams(loginRequest.getOriginParams())
.setHttpServletRequest(loginRequest.getHttpServletRequest());
final ThirdPartyUser bizUser = thirdPartyLoginService.login(thirdPartyLoginRequest);
String dbUserName = String.format("%s_%s", loginType, bizUser.getUsername());
Optional<UserInfoDO> powerJobUserOpt = userInfoRepository.findByUsername(dbUserName);
// 如果不存在用户,先同步创建用户
if (!powerJobUserOpt.isPresent()) {
UserInfoDO newUser = new UserInfoDO();
newUser.setUsername(dbUserName);
// 写入账号体系类型
newUser.setAccountType(loginType);
newUser.setOriginUsername(bizUser.getUsername());
newUser.setTokenLoginVerifyInfo(JsonUtils.toJSONString(bizUser.getTokenLoginVerifyInfo()));
// 同步素材
newUser.setEmail(bizUser.getEmail());
newUser.setPhone(bizUser.getPhone());
newUser.setNick(bizUser.getNick());
newUser.setWebHook(bizUser.getWebHook());
newUser.setExtra(bizUser.getExtra());
Loggers.WEB.info("[PowerJobLoginService] sync user to PowerJobUserSystem: {}", dbUserName);
userInfoRepository.saveAndFlush(newUser);
powerJobUserOpt = userInfoRepository.findByUsername(dbUserName);
} else {
UserInfoDO dbUserInfoDO = powerJobUserOpt.get();
checkUserStatus(dbUserInfoDO);
// 更新二次校验的 TOKEN 信息
dbUserInfoDO.setTokenLoginVerifyInfo(JsonUtils.toJSONString(bizUser.getTokenLoginVerifyInfo()));
dbUserInfoDO.setGmtModified(new Date());
userInfoRepository.saveAndFlush(dbUserInfoDO);
}
PowerJobUser ret = new PowerJobUser();
// 理论上 100% 存在
if (powerJobUserOpt.isPresent()) {
final UserInfoDO dbUser = powerJobUserOpt.get();
BeanUtils.copyProperties(dbUser, ret);
ret.setUsername(dbUserName);
}
fillJwt(ret, Optional.ofNullable(bizUser.getTokenLoginVerifyInfo()).map(TokenLoginVerifyInfo::getEncryptedToken).orElse(null));
return ret;
}
@Override
public Optional<PowerJobUser> ifLogin(HttpServletRequest httpServletRequest) {
final Optional<JwtBody> jwtBodyOpt = parseJwt(httpServletRequest);
if (!jwtBodyOpt.isPresent()) {
return Optional.empty();
}
JwtBody jwtBody = jwtBodyOpt.get();
Optional<UserInfoDO> dbUserInfoOpt = userInfoRepository.findByUsername(jwtBody.getUsername());
if (!dbUserInfoOpt.isPresent()) {
throw new PowerJobAuthException(ErrorCodes.USER_NOT_EXIST);
}
UserInfoDO dbUser = dbUserInfoOpt.get();
checkUserStatus(dbUser);
PowerJobUser powerJobUser = new PowerJobUser();
String tokenLoginVerifyInfoStr = dbUser.getTokenLoginVerifyInfo();
TokenLoginVerifyInfo tokenLoginVerifyInfo = Optional.ofNullable(tokenLoginVerifyInfoStr).map(x -> JsonUtils.parseObjectIgnoreException(x, TokenLoginVerifyInfo.class)).orElse(new TokenLoginVerifyInfo());
// DB 中的 encryptedToken 存在,代表需要二次校验
if (StringUtils.isNotEmpty(tokenLoginVerifyInfo.getEncryptedToken())) {
if (!StringUtils.equals(jwtBody.getEncryptedToken(), tokenLoginVerifyInfo.getEncryptedToken())) {
throw new PowerJobAuthException(ErrorCodes.INVALID_TOKEN);
}
ThirdPartyLoginService thirdPartyLoginService = code2ThirdPartyLoginService.get(dbUser.getAccountType());
boolean tokenLoginVerifyOk = thirdPartyLoginService.tokenLoginVerify(dbUser.getOriginUsername(), tokenLoginVerifyInfo);
if (!tokenLoginVerifyOk) {
throw new PowerJobAuthException(ErrorCodes.USER_AUTH_FAILED);
}
}
BeanUtils.copyProperties(dbUser, powerJobUser);
// 兼容某些直接通过 ifLogin 判断登录的场景
LoginUserHolder.set(powerJobUser);
return Optional.of(powerJobUser);
}
/**
* 检查 user 状态
* @param dbUser user
*/
private void checkUserStatus(UserInfoDO dbUser) {
int accountStatus = Optional.ofNullable(dbUser.getStatus()).orElse(SwitchableStatus.ENABLE.getV());
if (accountStatus == SwitchableStatus.DISABLE.getV()) {
throw new PowerJobAuthException(ErrorCodes.USER_DISABLED);
}
}
private ThirdPartyLoginService fetchBizLoginService(String loginType) {
final ThirdPartyLoginService loginService = code2ThirdPartyLoginService.get(loginType);
if (loginService == null) {
throw new PowerJobAuthException(ErrorCodes.INVALID_REQUEST, "can't find ThirdPartyLoginService by type: " + loginType);
}
return loginService;
}
private void fillJwt(PowerJobUser powerJobUser, String encryptedToken) {
// 不能下发 userId容易被轮询爆破
JwtBody jwtBody = new JwtBody();
jwtBody.setUsername(powerJobUser.getUsername());
if (StringUtils.isNotEmpty(encryptedToken)) {
jwtBody.setEncryptedToken(encryptedToken);
}
Map<String, Object> jwtMap = JsonUtils.parseMap(JsonUtils.toJSONString(jwtBody));
powerJobUser.setJwtToken(jwtService.build(jwtMap, null));
}
@SneakyThrows
private Optional<JwtBody> parseJwt(HttpServletRequest httpServletRequest) {
// header、cookie 都能获取
String jwtStr = HttpServletUtils.fetchFromHeader(AuthConstants.JWT_NAME, httpServletRequest);
if (StringUtils.isEmpty(jwtStr)) {
jwtStr = HttpServletUtils.fetchFromHeader(AuthConstants.OLD_JWT_NAME, httpServletRequest);
}
/*
开发阶段跨域无法简单传输 cookies暂时采取 header 方案传输 JWT
if (StringUtils.isEmpty(jwtStr)) {
for (Cookie cookie : Optional.ofNullable(httpServletRequest.getCookies()).orElse(new Cookie[]{})) {
if (cookie.getName().equals(AuthConstants.JWT_NAME)) {
jwtStr = cookie.getValue();
}
}
}
*/
if (StringUtils.isEmpty(jwtStr)) {
return Optional.empty();
}
final Map<String, Object> jwtBodyMap = jwtService.parse(jwtStr, null).getResult();
if (MapUtils.isEmpty(jwtBodyMap)) {
return Optional.empty();
}
return Optional.ofNullable(JsonUtils.parseObject(JsonUtils.toJSONString(jwtBodyMap), JwtBody.class));
}
@Data
static class JwtBody implements Serializable {
private String username;
private String encryptedToken;
}
}

View File

@ -0,0 +1,64 @@
package tech.powerjob.server.auth.service.permission;
import tech.powerjob.server.auth.Permission;
import tech.powerjob.server.auth.Role;
import tech.powerjob.server.auth.RoleScope;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* PowerJob 鉴权服务
*
* @author tjq
* @since 2024/2/11
*/
public interface PowerJobPermissionService {
/**
* 判断用户是否有访问权限
* @param userId userId
* @param roleScope 权限范围
* @param target 权限目标ID
* @param permission 要求的权限
* @return 是否有权限
*/
boolean hasPermission(Long userId, RoleScope roleScope, Long target, Permission permission);
/**
* 授予用户角色
* @param roleScope 权限范围
* @param target 权限目标
* @param userId 用户ID
* @param role 角色
* @param extra 其他
*/
void grantRole(RoleScope roleScope, Long target, Long userId, Role role, String extra);
/**
* 回收用户角色
* @param roleScope 权限范围
* @param target 权限目标
* @param userId 用户ID
* @param role 角色
*/
void retrieveRole(RoleScope roleScope, Long target, Long userId, Role role);
/**
* 获取有相关权限的用户
* @param roleScope 角色范围
* @param target 目标
* @return 角色对应的用户列表user 可能重复,需要用 SET 去重save APP/namespace 等场景,创建人自动被授权成为 ADMIN如果用户在面板将自己添加到管理员就会存在2套授权机制2次授权出现重复
*/
Map<Role, Set<Long>> fetchUserWithPermissions(RoleScope roleScope, Long target);
/**
* 获取用户有权限的目标
* @param roleScope 角色范围
* @param userId 用户ID
* @return result
*/
Map<Role, List<Long>> fetchUserHadPermissionTargets(RoleScope roleScope, Long userId);
}

View File

@ -0,0 +1,175 @@
package tech.powerjob.server.auth.service.permission;
import com.google.common.collect.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.powerjob.server.auth.Permission;
import tech.powerjob.server.auth.Role;
import tech.powerjob.server.auth.RoleScope;
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 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(Long userId, RoleScope roleScope, Long target, Permission requiredPermission) {
final List<UserRoleDO> userRoleList = Optional.ofNullable(userRoleRepository.findAllByUserId(userId)).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());
// 处理全局权限
if (RoleScope.GLOBAL.getV() == userRole.getScope()) {
if (Role.ADMIN.equals(role)) {
return true;
}
globalRoles.add(role);
}
if (RoleScope.NAMESPACE.getV() == userRole.getScope()) {
namespaceId2Role.put(userRole.getTarget(), role);
}
if (RoleScope.APP.getV() == userRole.getScope()) {
appId2Role.put(userRole.getTarget(), role);
}
}
// 前置判断需要的权限(新增场景还没有 appId or namespaceId
if (requiredPermission == Permission.NONE) {
return true;
}
// 检验全局穿透权限
for (Role role : globalRoles) {
if (role.getPermissions().contains(requiredPermission)) {
return true;
}
}
// 无超级管理员权限,校验普通权限
if (RoleScope.APP.equals(roleScope)) {
return checkAppPermission(target, requiredPermission, appId2Role, namespaceId2Role);
}
if (RoleScope.NAMESPACE.equals(roleScope)) {
return checkNamespacePermission(target, requiredPermission, namespaceId2Role);
}
return false;
}
@Override
public void grantRole(RoleScope roleScope, Long target, Long userId, Role role, String extra) {
UserRoleDO userRoleDO = new UserRoleDO();
userRoleDO.setGmtCreate(new Date());
userRoleDO.setGmtModified(new Date());
userRoleDO.setExtra(extra);
userRoleDO.setScope(roleScope.getV());
userRoleDO.setTarget(target);
userRoleDO.setUserId(userId);
userRoleDO.setRole(role.getV());
userRoleRepository.saveAndFlush(userRoleDO);
log.info("[PowerJobPermissionService] [grantPermission] saveAndFlush userRole successfully: {}", userRoleDO);
}
@Override
public void retrieveRole(RoleScope roleScope, Long target, Long userId, Role role) {
List<UserRoleDO> originUserRole = userRoleRepository.findAllByScopeAndTargetAndRoleAndUserId(roleScope.getV(), target, role.getV(), userId);
log.info("[PowerJobPermissionService] [retrievePermission] origin rule: {}", originUserRole);
Optional.ofNullable(originUserRole).orElse(Collections.emptyList()).forEach(r -> {
userRoleRepository.deleteById(r.getId());
log.info("[PowerJobPermissionService] [retrievePermission] delete UserRole: {}", r);
});
}
@Override
public Map<Role, Set<Long>> fetchUserWithPermissions(RoleScope roleScope, Long target) {
List<UserRoleDO> permissionUserList = userRoleRepository.findAllByScopeAndTarget(roleScope.getV(), target);
Map<Role, Set<Long>> ret = Maps.newHashMap();
Optional.ofNullable(permissionUserList).orElse(Collections.emptyList()).forEach(userRoleDO -> {
Role role = Role.of(userRoleDO.getRole());
Set<Long> userIds = ret.computeIfAbsent(role, ignore -> Sets.newHashSet());
userIds.add(userRoleDO.getUserId());
});
return ret;
}
@Override
public Map<Role, List<Long>> fetchUserHadPermissionTargets(RoleScope roleScope, Long userId) {
Map<Role, List<Long>> ret = Maps.newHashMap();
List<UserRoleDO> userRoleDOList = userRoleRepository.findAllByUserIdAndScope(userId, roleScope.getV());
Optional.ofNullable(userRoleDOList).orElse(Collections.emptyList()).forEach(r -> {
Role role = Role.of(r.getRole());
List<Long> targetIds = ret.computeIfAbsent(role, ignore -> Lists.newArrayList());
targetIds.add(r.getTarget());
});
return ret;
}
private boolean checkAppPermission(Long targetId, Permission requiredPermission, Multimap<Long, Role> appId2Role, Multimap<Long, Role> namespaceId2Role) {
final Collection<Role> appRoles = appId2Role.get(targetId);
for (Role role : appRoles) {
if (role.getPermissions().contains(requiredPermission)) {
return true;
}
}
// 校验 namespace 穿透权限
Optional<AppInfoDO> appInfoOpt = appInfoRepository.findById(targetId);
if (!appInfoOpt.isPresent()) {
throw new IllegalArgumentException("can't find appInfo by appId in permission check: " + targetId);
}
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;
}
}
return false;
}
private boolean checkNamespacePermission(Long targetId, Permission requiredPermission, Multimap<Long, Role> namespaceId2Role) {
Collection<Role> namespaceRoles = namespaceId2Role.get(targetId);
for (Role role : namespaceRoles) {
if (role.getPermissions().contains(requiredPermission)) {
return true;
}
}
return false;
}
}