diff --git a/java/README.md b/java/README.md new file mode 100644 index 0000000..2289edf --- /dev/null +++ b/java/README.md @@ -0,0 +1,165 @@ +# API签名工具 - Java实现 + +## 项目结构 + +根据实际项目文件结构: + +```plaintext +java/ +├── pom.xml # Maven项目配置 +├── src/ +│ ├── main/ +│ │ └── java/ +│ │ └── com/ +│ │ └── soundforce/ +│ │ └── apisign/ +│ │ ├── ApiSigner.java # 签名工具实现 +│ │ ├── Main.java # 命令行入口 +│ │ ├── SignOptions.java # 签名配置 +│ │ └── SignatureAlgorithm.java # 签名算法 +│ └── test/ +│ └── java/ # 测试代码目录 +└── target/ # 构建输出目录 +``` + +## 使用方法 + +### 构建项目 + +```bash +mvn clean package +``` + +### 运行命令行工具 + +```bash +java -jar target/apisign-1.0.0.jar [选项] +``` + +### 命令行选项 + +| 选项 | 描述 | +|------|------| +| `-a, --algorithm` | 签名算法: MD5, SHA1, SHA256, HMAC-SHA256 | +| `-u, --url` | API基础URL | +| `-p, --param` | 请求参数,格式为key=value,可多次使用 | +| `-k, --key` | 访问密钥ID | +| `-s, --secret` | 密钥 | +| `-c, --channel` | 合作渠道方ID | +| `-h, --help` | 显示帮助信息 | + +### 常用命令示例 + +**基本用法** + +```bash +java -jar target/apisign-1.0.0.jar +``` + +**自定义参数** + +```bash +java -jar target/apisign-1.0.0.jar \ + -u "https://api.example.com/user/info" \ + -p "userId=12345" -p "action=getInfo" \ + -k "YOUR_ACCESS_KEY" \ + -s "YOUR_SECRET_KEY" \ + -c "3" +``` + +**指定签名算法** + +```bash +java -jar target/apisign-1.0.0.jar -a SHA256 +``` + +**帮助信息** + +```bash +java -jar target/apisign-1.0.0.jar --help +``` + +### API接口测试实例 + +使用真实API接口进行测试: + +```bash +# 未签名的API调用测试 - 返回错误 +curl "https://api-v1.sound-force.com:8443/p/album/single/media-url?channelId=3&singleId=381980" +# 返回: {"code":400,"data":null,"msg":"Missing AccessKeyId","success":false} + +# 生成访问https://api-v1.sound-force.com:8443/p/album/single/media-url的签名URL +java -jar target/apisign-1.0.0.jar \ + -a MD5 \ + -u "https://api-v1.sound-force.com:8443/p/album/single/media-url" \ + -p "singleId=381980" \ + -k "YOUR_ACCESS_KEY" \ + -s "YOUR_SECRET_KEY" \ + -c "3" + +# 使用curl测试API接口 +signed_url=$(java -jar target/apisign-1.0.0.jar \ + -a MD5 \ + -u "https://api-v1.sound-force.com:8443/p/album/single/media-url" \ + -p "singleId=381980" \ + -k "YOUR_ACCESS_KEY" \ + -s "YOUR_SECRET_KEY" \ + -c "3" | grep -A 1 "签名后的URL:" | tail -n 1) +curl -v "$signed_url" +``` + +请注意: + +- 替换`YOUR_ACCESS_KEY`为实际的访问密钥ID +- 替换`YOUR_SECRET_KEY`为实际的密钥 +- 示例使用的渠道ID为`3`,请根据实际情况调整 + +使用有效的密钥和签名后,API接口将返回成功响应(状态码200)并提供媒体URL数据。 + +### 代码集成 + +```java +import com.soundforce.apisign.ApiSigner; +import com.soundforce.apisign.SignOptions; +import com.soundforce.apisign.SignatureAlgorithm; + +import java.util.HashMap; +import java.util.Map; + +// 创建签名选项 +SignOptions options = new SignOptions(SignatureAlgorithm.MD5); + +// 创建签名工具 +ApiSigner signer = new ApiSigner(options); + +// 准备请求参数 +Map params = new HashMap<>(); +params.put("singleId", "381980"); + +// 执行签名 +Map signedParams = signer.signRequest( + params, + "YOUR_ACCESS_KEY", + "YOUR_SECRET_KEY", + "3" +); + +// 或签名URL +String signedUrl = signer.signUrl( + "https://api-v1.sound-force.com:8443/p/album/single/media-url", + params, + "YOUR_ACCESS_KEY", + "YOUR_SECRET_KEY", + "3" +); +``` + +### 环境变量 + +该工具支持从`.env`文件加载以下配置: + +- `ACCESS_KEY_ID`: 访问密钥ID +- `SECRET_KEY`: 密钥 +- `CHANNEL_ID`: 渠道ID +- `SIGN_ALGORITHM`: 签名算法 +- `API_BASE_URL`: API基础URL diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 0000000..dbd3230 --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + com.soundforce + api-signer + 1.0.0 + jar + + API Signer + API签名工具,提供请求签名与验证功能 + + + UTF-8 + 21 + ${java.version} + ${java.version} + + + + + junit + junit + 4.13.2 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.version} + ${java.version} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + true + com.soundforce.apisign.Main + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + + + \ No newline at end of file diff --git a/java/src/main/java/com/soundforce/apisign/ApiSigner.java b/java/src/main/java/com/soundforce/apisign/ApiSigner.java new file mode 100644 index 0000000..91761da --- /dev/null +++ b/java/src/main/java/com/soundforce/apisign/ApiSigner.java @@ -0,0 +1,331 @@ +package com.soundforce.apisign; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * API签名工具 + */ +public record ApiSigner(SignOptions options) { + private static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("^[a-zA-Z0-9]*$"); + + /** + * 创建API签名工具 + * + * @param options 签名选项,如为null则使用默认选项 + */ + public ApiSigner(SignOptions options) { + this.options = options != null ? options : new SignOptions(); + } + + /** + * 生成随机字符串 + * + * @return 一个基于当前时间的随机字符串 + */ + public String generateNonce() { + return System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8); + } + + /** + * 获取当前时间戳(毫秒) + * + * @return 当前的Unix时间戳(毫秒) + */ + public long getTimestamp() { + return System.currentTimeMillis(); + } + + /** + * 对请求进行签名 + * + * @param params 请求参数 + * @param accessKeyId 访问密钥ID + * @param secretKey 密钥 + * @param channelId 合作渠道方ID + * @return 添加了签名的完整参数 + */ + public Map signRequest(Map params, String accessKeyId, String secretKey, String channelId) { + Map signParams = new HashMap<>(params); + + long timestamp = getTimestamp(); + signParams.put(options.getKeyName(), accessKeyId); + signParams.put(options.getChannelIdName(), channelId); + signParams.put(options.getTimestampName(), String.valueOf(timestamp)); + signParams.put(options.getNonceName(), generateNonce()); + + String signature = calculateSignature(signParams, secretKey); + + signParams.put(options.getSignatureName(), signature); + + return signParams; + } + + /** + * 对URL进行签名 + * + * @param baseUrl 基础URL地址 + * @param params 请求参数 + * @param accessKeyId 访问密钥ID + * @param secretKey 密钥 + * @param channelId 合作渠道方ID + * @return 添加了签名的完整URL + */ + public String signUrl(String baseUrl, Map params, String accessKeyId, String secretKey, String channelId) { + Map signedParams = signRequest(params, accessKeyId, secretKey, channelId); + + String queryString = buildQueryString(signedParams); + + if (baseUrl.contains("?")) { + return baseUrl + "&" + queryString; + } else { + return baseUrl + "?" + queryString; + } + } + + /** + * 构建URL查询字符串 + * + * @param params 参数 + * @return 查询字符串 + */ + private String buildQueryString(Map params) { + StringBuilder result = new StringBuilder(); + boolean first = true; + + Map sortedParams = new TreeMap<>(params); + + for (Entry entry : sortedParams.entrySet()) { + if (first) { + first = false; + } else { + result.append("&"); + } + + result.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)).append("=").append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + } + + return result.toString(); + } + + /** + * 计算签名 + * + * @param params 请求参数 + * @param secretKey 密钥 + * @return 签名字符串 + */ + public String calculateSignature(Map params, String secretKey) { + String signingString = createSigningString(params); + + signingString = signingString + "&key=" + secretKey; + + try { + return switch (options.getAlgorithm()) { + case SHA1 -> sha1(signingString); + case SHA256 -> sha256(signingString); + case HMAC_SHA256 -> hmacSha256(signingString, secretKey); + default -> md5(signingString); + }; + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException("计算签名时发生错误", e); + } + } + + /** + * 创建用于签名的规范化字符串 + * + * @param params 请求参数 + * @return 按键名排序并拼接的字符串 + */ + public String createSigningString(Map params) { + Map sortedParams = new TreeMap<>(); + for (Entry entry : params.entrySet()) { + if (!entry.getKey().equals(options.getSignatureName())) { + sortedParams.put(entry.getKey(), entry.getValue()); + } + } + + List parts = new ArrayList<>(); + for (Entry entry : sortedParams.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + if (needsUrlEncode(value)) { + value = URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + parts.add(key + "=" + value); + } + + return String.join("&", parts); + } + + /** + * 判断是否需要对字符串进行URL编码 + * + * @param s 需要判断的字符串 + * @return 如果包含非字母数字字符,返回true,否则返回false + */ + private boolean needsUrlEncode(String s) { + return !ALPHANUMERIC_PATTERN.matcher(s).matches(); + } + + /** + * 验证签名结果 + */ + public record SignVerifyResult(boolean valid, String error) { + + /** + * 签名是否有效 + * + * @return 如果签名有效,返回true,否则返回false + */ + @Override + public boolean valid() { + return valid; + } + + /** + * 获取错误信息 + * + * @return 错误信息,如果没有错误则返回null + */ + @Override + public String error() { + return error; + } + } + + /** + * 验证签名 + * + * @param params 所有请求参数,包括签名 + * @param secretKey 密钥 + * @param maxAgeMs 允许的最大时间差(毫秒) + * @return 验证结果 + */ + public SignVerifyResult verifySignature(Map params, String secretKey, long maxAgeMs) { + if (!params.containsKey(options.getKeyName())) + return new SignVerifyResult(false, "缺少参数: " + options.getKeyName()); + + if (!params.containsKey(options.getChannelIdName())) { + return new SignVerifyResult(false, "缺少参数: " + options.getChannelIdName()); + } + + if (!params.containsKey(options.getTimestampName())) { + return new SignVerifyResult(false, "缺少参数: " + options.getTimestampName()); + } + + try { + long timestamp = Long.parseLong(params.get(options.getTimestampName())); + long now = getTimestamp(); + if (Math.abs(now - timestamp) > maxAgeMs) { + return new SignVerifyResult(false, "时间戳过期"); + } + } catch (NumberFormatException e) { + return new SignVerifyResult(false, "无效的时间戳"); + } + + if (!params.containsKey(options.getNonceName())) { + return new SignVerifyResult(false, "缺少参数: " + options.getNonceName()); + } + + if (!params.containsKey(options.getSignatureName())) { + return new SignVerifyResult(false, "缺少参数: " + options.getSignatureName()); + } + + String providedSignature = params.get(options.getSignatureName()); + + String expectedSignature = calculateSignature(params, secretKey); + + if (expectedSignature.equals(providedSignature)) { + return new SignVerifyResult(true, null); + } else { + return new SignVerifyResult(false, "签名不匹配"); + } + } + + /** + * 计算MD5签名 + * + * @param content 内容 + * @return MD5签名 + * @throws NoSuchAlgorithmException 如果算法不存在 + */ + private String md5(String content) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } + + /** + * 计算SHA1签名 + * + * @param content 内容 + * @return SHA1签名 + * @throws NoSuchAlgorithmException 如果算法不存在 + */ + private String sha1(String content) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } + + /** + * 计算SHA256签名 + * + * @param content 内容 + * @return SHA256签名 + * @throws NoSuchAlgorithmException 如果算法不存在 + */ + private String sha256(String content) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } + + /** + * 计算HMAC-SHA256签名 + * + * @param content 内容 + * @param key 密钥 + * @return HMAC-SHA256签名 + * @throws NoSuchAlgorithmException 如果算法不存在 + * @throws InvalidKeyException 如果密钥无效 + */ + private String hmacSha256(String content, String key) throws NoSuchAlgorithmException, InvalidKeyException { + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + hmac.init(keySpec); + byte[] digest = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } + + /** + * 将字节数组转换为十六进制字符串 + * + * @param bytes 字节数组 + * @return 十六进制字符串 + */ + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/java/src/main/java/com/soundforce/apisign/Main.java b/java/src/main/java/com/soundforce/apisign/Main.java new file mode 100644 index 0000000..42852d2 --- /dev/null +++ b/java/src/main/java/com/soundforce/apisign/Main.java @@ -0,0 +1,272 @@ +package com.soundforce.apisign; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * API签名工具命令行实现 + */ +public class Main { + private static final String ENV_FILE = ".env"; + private static final String ENV_ACCESS_KEY_ID = "ACCESS_KEY_ID"; + private static final String ENV_SECRET_KEY = "SECRET_KEY"; + private static final String ENV_CHANNEL_ID = "CHANNEL_ID"; + private static final String ENV_API_BASE_URL = "API_BASE_URL"; + private static final String ENV_SIGN_ALGORITHM = "SIGN_ALGORITHM"; + + private static final String DEFAULT_ACCESS_KEY_ID = "test-access-key-id"; + private static final String DEFAULT_SECRET_KEY = "test-secret-key"; + private static final String DEFAULT_CHANNEL_ID = "test-channel-id"; + private static final String DEFAULT_API_BASE_URL = "https://api.example.com/v1/data"; + private static final String DEFAULT_ALGORITHM = "MD5"; + private static final String DEFAULT_MODE = "url"; + + /** + * 应用程序入口点 + * + * @param args 命令行参数 + */ + public static void main(String[] args) { + // 加载环境变量 + Properties env = loadEnvFile(); + + // 解析命令行参数 + CommandLineArgs cmdArgs = parseArgs(args, env); + + // 创建签名工具 + SignOptions options = new SignOptions(); + options.setAlgorithm(SignatureAlgorithm.fromString(cmdArgs.algorithm)); + ApiSigner signer = new ApiSigner(options); + + // 打印配置信息 + System.out.println("===================== API签名示例 ====================="); + System.out.println("AccessKeyId: " + cmdArgs.accessKeyId); + System.out.println("ChannelId: " + cmdArgs.channelId); + System.out.println("SecretKey: " + cmdArgs.secretKey); + System.out.println("签名算法: " + options.getAlgorithm()); + System.out.println("基础URL: " + cmdArgs.url); + System.out.println("请求参数: " + cmdArgs.params); + + // 根据操作模式执行不同操作 + switch (cmdArgs.mode) { + case "url": + // 签名URL + String signedUrl = signer.signUrl(cmdArgs.url, cmdArgs.params, cmdArgs.accessKeyId, cmdArgs.secretKey, cmdArgs.channelId); + System.out.println("\n签名后的URL:"); + System.out.println(signedUrl); + break; + + case "params": + // 获取签名后的参数 + Map signedParams = signer.signRequest(cmdArgs.params, cmdArgs.accessKeyId, cmdArgs.secretKey, cmdArgs.channelId); + System.out.println("\n签名后的参数:"); + for (Map.Entry entry : signedParams.entrySet()) { + System.out.println(" " + entry.getKey() + ": " + entry.getValue()); + } + break; + + case "verify": + // 验证签名 + ApiSigner.SignVerifyResult result = signer.verifySignature(cmdArgs.params, cmdArgs.secretKey, 300000); + System.out.println("\n签名验证结果:"); + if (result.valid()) { + System.out.println(" 验证成功"); + } else { + System.out.println(" 验证失败: " + result.error()); + } + break; + + default: + System.err.println("未知操作模式: " + cmdArgs.mode); + System.exit(1); + } + + // 演示不同算法的签名结果 + demonstrateAlgorithms(cmdArgs.params, cmdArgs.accessKeyId, cmdArgs.secretKey); + } + + /** + * 加载.env文件 + * + * @return 环境变量属性 + */ + private static Properties loadEnvFile() { + Properties properties = new Properties(); + + // 首先尝试当前目录 + File envFile = new File(ENV_FILE); + if (!envFile.exists()) { + // 尝试父目录 + envFile = new File(".." + File.separator + ENV_FILE); + } + + if (envFile.exists()) { + try (FileInputStream fis = new FileInputStream(envFile)) { + properties.load(fis); + } catch (IOException e) { + System.err.println("警告: 无法加载.env文件: " + e.getMessage()); + } + } + + return properties; + } + + /** + * 演示不同算法的签名结果 + * + * @param params 参数 + * @param accessKeyId 访问密钥ID + * @param secretKey 密钥 + */ + private static void demonstrateAlgorithms(Map params, String accessKeyId, String secretKey) { + System.out.println("\n不同算法的签名结果:"); + + SignatureAlgorithm[] algorithms = {SignatureAlgorithm.MD5, SignatureAlgorithm.SHA1, SignatureAlgorithm.SHA256, SignatureAlgorithm.HMAC_SHA256}; + + for (SignatureAlgorithm alg : algorithms) { + SignOptions options = new SignOptions(); + options.setAlgorithm(alg); + ApiSigner signer = new ApiSigner(options); + + // 添加必要的参数用于签名 + Map signParams = new HashMap<>(params); + signParams.put(options.getKeyName(), accessKeyId); + + String signature = signer.calculateSignature(signParams, secretKey); + System.out.println(" " + alg + ": " + signature); + } + } + + /** + * 命令行参数 + */ + private static class CommandLineArgs { + String algorithm = DEFAULT_ALGORITHM; + String mode = DEFAULT_MODE; + String url = DEFAULT_API_BASE_URL; + String accessKeyId = DEFAULT_ACCESS_KEY_ID; + String secretKey = DEFAULT_SECRET_KEY; + String channelId = DEFAULT_CHANNEL_ID; + Map params = new HashMap<>(); + } + + /** + * 解析命令行参数 + * + * @param args 命令行参数 + * @param env 环境变量 + * @return 解析后的参数对象 + */ + private static CommandLineArgs parseArgs(String[] args, Properties env) { + CommandLineArgs result = new CommandLineArgs(); + + // 从环境变量加载默认值 + result.accessKeyId = getEnvOrDefault(env, ENV_ACCESS_KEY_ID, DEFAULT_ACCESS_KEY_ID); + result.secretKey = getEnvOrDefault(env, ENV_SECRET_KEY, DEFAULT_SECRET_KEY); + result.channelId = getEnvOrDefault(env, ENV_CHANNEL_ID, DEFAULT_CHANNEL_ID); + result.url = getEnvOrDefault(env, ENV_API_BASE_URL, DEFAULT_API_BASE_URL); + result.algorithm = getEnvOrDefault(env, ENV_SIGN_ALGORITHM, DEFAULT_ALGORITHM); + + // 解析命令行参数 + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + + if ("-h".equals(arg) || "--help".equals(arg)) { + printHelp(); + System.exit(0); + } else if ("-a".equals(arg) || "--algorithm".equals(arg)) { + if (i + 1 < args.length) { + result.algorithm = args[++i]; + } + } else if ("-m".equals(arg) || "--mode".equals(arg)) { + if (i + 1 < args.length) { + result.mode = args[++i]; + } + } else if ("-u".equals(arg) || "--url".equals(arg)) { + if (i + 1 < args.length) { + result.url = args[++i]; + } + } else if ("-k".equals(arg) || "--key".equals(arg)) { + if (i + 1 < args.length) { + result.accessKeyId = args[++i]; + } + } else if ("-s".equals(arg) || "--secret".equals(arg)) { + if (i + 1 < args.length) { + result.secretKey = args[++i]; + } + } else if ("-c".equals(arg) || "--channel".equals(arg)) { + if (i + 1 < args.length) { + result.channelId = args[++i]; + } + } else if ("-p".equals(arg) || "--param".equals(arg)) { + if (i + 1 < args.length) { + String paramValue = args[++i]; + String[] parts = paramValue.split("=", 2); + if (parts.length == 2) { + result.params.put(parts[0], parts[1]); + } + } + } + } + + // 如果没有参数,使用默认参数进行演示 + if (result.params.isEmpty()) { + result.params.put("userId", "12345"); + result.params.put("action", "getData"); + result.params.put("data", "测试数据"); // 包含非ASCII字符,测试URL编码 + } + + return result; + } + + /** + * 从环境变量获取值或使用默认值 + * + * @param env 环境变量 + * @param key 键 + * @param defaultValue 默认值 + * @return 环境变量值或默认值 + */ + private static String getEnvOrDefault(Properties env, String key, String defaultValue) { + // 先检查系统环境变量 + String value = System.getenv(key); + if (value != null && !value.isEmpty()) { + return value; + } + + // 然后检查.env文件 + value = env.getProperty(key); + if (value != null && !value.isEmpty()) { + return value; + } + + // 最后使用默认值 + return defaultValue; + } + + /** + * 打印帮助信息 + */ + private static void printHelp() { + System.out.println("API签名工具 - 命令行接口"); + System.out.println(); + System.out.println("用法: java -jar api-signer.jar [选项]"); + System.out.println(); + System.out.println("选项:"); + System.out.println(" -a, --algorithm 签名算法: MD5, SHA1, SHA256, HMAC_SHA256 (默认: MD5)"); + System.out.println(" -k, --key 访问密钥ID (默认: 环境变量ACCESS_KEY_ID)"); + System.out.println(" -c, --channel 合作渠道方ID (默认: 环境变量CHANNEL_ID)"); + System.out.println(" -s, --secret 密钥 (默认: 环境变量SECRET_KEY)"); + System.out.println(" -u, --url 基础URL地址 (默认: 环境变量API_BASE_URL)"); + System.out.println(" -p, --param 请求参数,格式为key=value,可多次指定"); + System.out.println(" -m, --mode 操作模式: url, params, verify (默认: url)"); + System.out.println(" -h, --help 显示帮助信息"); + System.out.println(); + System.out.println("示例:"); + System.out.println(" java -jar api-signer.jar -a MD5 -u \"https://api.example.com/v1/data\" -p \"userId=12345\" -p \"action=getData\""); + } +} \ No newline at end of file diff --git a/java/src/main/java/com/soundforce/apisign/SignOptions.java b/java/src/main/java/com/soundforce/apisign/SignOptions.java new file mode 100644 index 0000000..9bd3d62 --- /dev/null +++ b/java/src/main/java/com/soundforce/apisign/SignOptions.java @@ -0,0 +1,163 @@ +package com.soundforce.apisign; + +/** + * 签名选项 + */ +public class SignOptions { + /** + * 签名算法 + */ + private SignatureAlgorithm algorithm = SignatureAlgorithm.MD5; + /** + * AccessKeyId参数名 + */ + private String keyName = "AccessKeyId"; + /** + * 合作渠道方ID参数名 + */ + private String channelIdName = "channelId"; + /** + * 时间戳参数名 + */ + private String timestampName = "timestamp"; + /** + * 随机字符串参数名 + */ + private String nonceName = "nonce"; + /** + * 签名参数名 + */ + private String signatureName = "sign"; + + /** + * 创建默认签名选项 + */ + public SignOptions() { + // 使用默认值 + } + + /** + * 获取签名算法 + * + * @return 签名算法 + */ + public SignatureAlgorithm getAlgorithm() { + return algorithm; + } + + /** + * 设置签名算法 + * + * @param algorithm 签名算法 + * @return 当前对象,支持链式调用 + */ + public SignOptions setAlgorithm(SignatureAlgorithm algorithm) { + this.algorithm = algorithm; + return this; + } + + /** + * 获取AccessKeyId参数名 + * + * @return AccessKeyId参数名 + */ + public String getKeyName() { + return keyName; + } + + /** + * 设置AccessKeyId参数名 + * + * @param keyName AccessKeyId参数名 + * @return 当前对象,支持链式调用 + */ + public SignOptions setKeyName(String keyName) { + this.keyName = keyName; + return this; + } + + /** + * 获取合作渠道方ID参数名 + * + * @return 合作渠道方ID参数名 + */ + public String getChannelIdName() { + return channelIdName; + } + + /** + * 设置合作渠道方ID参数名 + * + * @param channelIdName 合作渠道方ID参数名 + * @return 当前对象,支持链式调用 + */ + public SignOptions setChannelIdName(String channelIdName) { + this.channelIdName = channelIdName; + return this; + } + + /** + * 获取时间戳参数名 + * + * @return 时间戳参数名 + */ + public String getTimestampName() { + return timestampName; + } + + /** + * 设置时间戳参数名 + * + * @param timestampName 时间戳参数名 + * @return 当前对象,支持链式调用 + */ + public SignOptions setTimestampName(String timestampName) { + this.timestampName = timestampName; + return this; + } + + /** + * 获取随机字符串参数名 + * + * @return 随机字符串参数名 + */ + public String getNonceName() { + return nonceName; + } + + /** + * 设置随机字符串参数名 + * + * @param nonceName 随机字符串参数名 + * @return 当前对象,支持链式调用 + */ + public SignOptions setNonceName(String nonceName) { + this.nonceName = nonceName; + return this; + } + + /** + * 获取签名参数名 + * + * @return 签名参数名 + */ + public String getSignatureName() { + return signatureName; + } + + /** + * 设置签名参数名 + * + * @param signatureName 签名参数名 + * @return 当前对象,支持链式调用 + */ + public SignOptions setSignatureName(String signatureName) { + this.signatureName = signatureName; + return this; + } + + @Override + public String toString() { + return "SignOptions{" + "algorithm=" + algorithm + ", keyName='" + keyName + '\'' + ", channelIdName='" + channelIdName + '\'' + ", timestampName='" + timestampName + '\'' + ", nonceName='" + nonceName + '\'' + ", signatureName='" + signatureName + '\'' + '}'; + } +} \ No newline at end of file diff --git a/java/src/main/java/com/soundforce/apisign/SignatureAlgorithm.java b/java/src/main/java/com/soundforce/apisign/SignatureAlgorithm.java new file mode 100644 index 0000000..c0f719b --- /dev/null +++ b/java/src/main/java/com/soundforce/apisign/SignatureAlgorithm.java @@ -0,0 +1,57 @@ +package com.soundforce.apisign; + +/** + * 签名算法类型 + */ +public enum SignatureAlgorithm { + /** + * MD5算法(默认、最快) + */ + MD5("MD5"), + /** + * SHA1算法 + */ + SHA1("SHA1"), + /** + * SHA256算法 + */ + SHA256("SHA256"), + /** + * HMAC-SHA256算法(最安全) + */ + HMAC_SHA256("HMAC-SHA256"); + + private final String description; + + SignatureAlgorithm(String description) { + this.description = description; + } + + /** + * 从字符串解析算法类型 + * + * @param algorithm 算法字符串 + * @return 签名算法枚举 + * @throws IllegalArgumentException 如果算法无效 + */ + public static SignatureAlgorithm fromString(String algorithm) { + if (algorithm == null) { + return MD5; + } + + String upperAlgorithm = algorithm.toUpperCase(); + + return switch (upperAlgorithm) { + case "MD5" -> MD5; + case "SHA1" -> SHA1; + case "SHA256" -> SHA256; + case "HMAC_SHA256", "HMACSHA256", "HMAC-SHA256" -> HMAC_SHA256; + default -> throw new IllegalArgumentException("无效的签名算法: " + algorithm); + }; + } + + @Override + public String toString() { + return description; + } +} \ No newline at end of file diff --git a/kotlin/.gitattributes b/kotlin/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/kotlin/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/kotlin/.gitignore b/kotlin/.gitignore new file mode 100644 index 0000000..1b6985c --- /dev/null +++ b/kotlin/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/kotlin/README.md b/kotlin/README.md new file mode 100644 index 0000000..d2a6e9f --- /dev/null +++ b/kotlin/README.md @@ -0,0 +1,177 @@ +# API签名工具 - Kotlin实现 + +## 项目结构 + +根据实际项目文件结构: + +```plaintext +kotlin/ +├── build.gradle.kts # Gradle构建文件 +├── gradle/ # Gradle包装器目录 +├── gradlew # Gradle包装器脚本(Unix) +├── gradlew.bat # Gradle包装器脚本(Windows) +├── gradle.properties # Gradle属性配置 +├── settings.gradle.kts # Gradle设置 +├── src/ +│ └── main/ +│ └── kotlin/ +│ └── com/ +│ └── soundforce/ +│ └── apisign/ +│ ├── ApiSigner.kt # 签名工具实现 +│ ├── Main.kt # 命令行接口 +│ ├── SignOptions.kt # 签名配置 +│ └── SignatureAlgorithm.kt # 签名算法 +├── build/ # 构建输出目录 +└── .gradle/ # Gradle缓存目录 +``` + +## 使用方法 + +### 构建项目 + +```bash +# 初始化Gradle包装器(如果尚未初始化) +gradle wrapper + +# 构建项目 +./gradlew build + +# 构建可执行JAR文件 +./gradlew shadowJar +``` + +这将在`build/libs/`目录下生成一个包含所有依赖的可执行JAR文件。 + +### 运行命令行工具 + +```bash +# 直接运行(不需要先构建) +./gradlew run --args="[选项]" + +# 或使用构建后的JAR文件 +java -jar build/libs/apisign-1.0.0.jar [选项] +``` + +### 命令行选项 + +| 选项 | 描述 | +|------|------| +| `-a, --algorithm` | 签名算法: MD5, SHA1, SHA256, HMAC-SHA256 | +| `-u, --url` | API基础URL | +| `-p, --param` | 请求参数,格式为key=value,可多次使用 | +| `-k, --key` | 访问密钥ID | +| `-s, --secret` | 密钥 | +| `-c, --channel` | 合作渠道方ID | +| `-h, --help` | 显示帮助信息 | + +### 常用命令示例 + +**基本用法** + +```bash +java -jar build/libs/apisign-1.0.0.jar +``` + +**自定义参数** + +```bash +java -jar build/libs/apisign-1.0.0.jar \ + -u "https://api.example.com/user/info" \ + -p "userId=12345" -p "action=getInfo" \ + -k "YOUR_ACCESS_KEY" \ + -s "YOUR_SECRET_KEY" \ + -c "3" +``` + +**指定签名算法** + +```bash +java -jar build/libs/apisign-1.0.0.jar -a SHA256 +``` + +**帮助信息** + +```bash +java -jar build/libs/apisign-1.0.0.jar --help +``` + +### API接口测试实例 + +使用真实API接口进行测试: + +```bash +# 未签名的API调用测试 - 返回错误 +curl "https://api-v1.sound-force.com:8443/p/album/single/media-url?channelId=3&singleId=381980" +# 返回: {"code":400,"data":null,"msg":"Missing AccessKeyId","success":false} + +# 生成访问https://api-v1.sound-force.com:8443/p/album/single/media-url的签名URL +java -jar build/libs/apisign-1.0.0.jar \ + -a MD5 \ + -u "https://api-v1.sound-force.com:8443/p/album/single/media-url" \ + -p "singleId=381980" \ + -k "YOUR_ACCESS_KEY" \ + -s "YOUR_SECRET_KEY" \ + -c "3" + +# 使用curl测试API接口 +signed_url=$(java -jar build/libs/apisign-1.0.0.jar \ + -a MD5 \ + -u "https://api-v1.sound-force.com:8443/p/album/single/media-url" \ + -p "singleId=381980" \ + -k "YOUR_ACCESS_KEY" \ + -s "YOUR_SECRET_KEY" \ + -c "3" | grep -A 1 "签名后的URL:" | tail -n 1) +curl -v "$signed_url" +``` + +请注意: + +- 替换`YOUR_ACCESS_KEY`为实际的访问密钥ID +- 替换`YOUR_SECRET_KEY`为实际的密钥 +- 示例使用的渠道ID为`3`,请根据实际情况调整 + +使用有效的密钥和签名后,API接口将返回成功响应(状态码200)并提供媒体URL数据。 + +### 代码集成 + +```kotlin +import com.soundforce.apisign.ApiSigner +import com.soundforce.apisign.SignOptions +import com.soundforce.apisign.SignatureAlgorithm + +// 创建签名工具 +val signer = ApiSigner() + +// 参数 +val params = mapOf( + "singleId" to "381980" +) + +// 执行签名 +val signedParams = signer.signRequest( + params, + "YOUR_ACCESS_KEY", + "YOUR_SECRET_KEY", + "3" +) + +// 或签名URL +val signedUrl = signer.signUrl( + "https://api-v1.sound-force.com:8443/p/album/single/media-url", + params, + "YOUR_ACCESS_KEY", + "YOUR_SECRET_KEY", + "3" +) +``` + +### 环境变量 + +该工具支持从`.env`文件加载以下配置: + +- `ACCESS_KEY_ID`: 访问密钥ID +- `SECRET_KEY`: 密钥 +- `CHANNEL_ID`: 渠道ID +- `SIGN_ALGORITHM`: 签名算法 +- `API_BASE_URL`: API基础URL diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts new file mode 100644 index 0000000..f10792b --- /dev/null +++ b/kotlin/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + kotlin("jvm") version "2.1.21" + application + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +group = "com.soundforce.apisign" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib")) + implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.6") + implementation("io.github.cdimascio:dotenv-kotlin:6.5.1") + testImplementation(kotlin("test")) +} + +application { + mainClass.set("com.soundforce.apisign.MainKt") +} + +tasks.test { + useJUnitPlatform() +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.withType { + kotlinOptions { + jvmTarget = "21" + } +} + +tasks.jar { + manifest { + attributes["Main-Class"] = "com.soundforce.apisign.MainKt" + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) +} + +tasks.shadowJar { + archiveBaseName.set("apisign") + archiveClassifier.set("") + archiveVersion.set("1.0.0") + mergeServiceFiles() +} \ No newline at end of file diff --git a/kotlin/gradle.properties b/kotlin/gradle.properties new file mode 100644 index 0000000..5154008 --- /dev/null +++ b/kotlin/gradle.properties @@ -0,0 +1,7 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/kotlin/gradle/libs.versions.toml b/kotlin/gradle/libs.versions.toml new file mode 100644 index 0000000..1cd4339 --- /dev/null +++ b/kotlin/gradle/libs.versions.toml @@ -0,0 +1,11 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +guava = "33.4.5-jre" + +[libraries] +guava = { module = "com.google.guava:guava", version.ref = "guava" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.1.20" } diff --git a/kotlin/gradle/wrapper/gradle-wrapper.jar b/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/kotlin/gradle/wrapper/gradle-wrapper.properties b/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kotlin/gradlew b/kotlin/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/kotlin/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kotlin/gradlew.bat b/kotlin/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/kotlin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts new file mode 100644 index 0000000..1c769f4 --- /dev/null +++ b/kotlin/settings.gradle.kts @@ -0,0 +1,15 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.14/userguide/multi_project_builds.html in the Gradle documentation. + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +rootProject.name = "kotlin" +include("app") diff --git a/kotlin/src/main/kotlin/com/soundforce/apisign/ApiSigner.kt b/kotlin/src/main/kotlin/com/soundforce/apisign/ApiSigner.kt new file mode 100644 index 0000000..c2e4f85 --- /dev/null +++ b/kotlin/src/main/kotlin/com/soundforce/apisign/ApiSigner.kt @@ -0,0 +1,226 @@ +package com.soundforce.apisign + +import java.net.URLEncoder +import java.security.MessageDigest +import java.time.Instant +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.math.abs +import kotlin.random.Random + +/** + * API签名工具 + */ +class ApiSigner(private val options: SignOptions = SignOptions()) { + + /** + * 生成随机字符串 + * @return 一个基于当前时间的随机字符串 + */ + fun generateNonce(): String { + val timestamp = getTimestamp() + val random = Random.nextInt(0, 1000000) + return "$timestamp$random" + } + + /** + * 获取当前时间戳(毫秒) + * @return 当前的Unix时间戳(毫秒) + */ + fun getTimestamp(): Long { + return Instant.now().toEpochMilli() + } + + /** + * 对请求进行签名 + * @param params 请求参数 + * @param accessKeyId 访问密钥ID + * @param secretKey 密钥 + * @param channelId 合作渠道方ID + * @return 添加了签名的完整参数 + */ + fun signRequest( + params: Map, accessKeyId: String, secretKey: String, channelId: String + ): Map { + val signedParams = params.toMutableMap() + + val timestamp = getTimestamp() + signedParams[options.keyName] = accessKeyId + signedParams[options.channelIdName] = channelId + signedParams[options.timestampName] = timestamp.toString() + signedParams[options.nonceName] = generateNonce() + + val signature = calculateSignature(signedParams, secretKey) + + signedParams[options.signatureName] = signature + + return signedParams + } + + /** + * 对URL进行签名 + * @param baseUrl 基础URL地址 + * @param params 请求参数 + * @param accessKeyId 访问密钥ID + * @param secretKey 密钥 + * @param channelId 合作渠道方ID + * @return 添加了签名的完整URL + */ + fun signUrl( + baseUrl: String, params: Map, accessKeyId: String, secretKey: String, channelId: String + ): String { + val signedParams = signRequest(params, accessKeyId, secretKey, channelId) + + val queryString = buildQueryString(signedParams) + + return if (baseUrl.contains("?")) { + "$baseUrl&$queryString" + } else { + "$baseUrl?$queryString" + } + } + + /** + * 构建URL查询字符串 + * @param params 参数 + * @return 查询字符串 + */ + private fun buildQueryString(params: Map): String { + return params.entries.sortedBy { it.key }.joinToString("&") { (key, value) -> + "${encodeURIComponent(key)}=${encodeURIComponent(value)}" + } + } + + /** + * URL编码 + */ + private fun encodeURIComponent(s: String): String { + return URLEncoder.encode(s, "UTF-8").replace("+", "%20").replace("%21", "!").replace("%27", "'") + .replace("%28", "(").replace("%29", ")").replace("%7E", "~") + } + + /** + * 计算签名 + * @param params 请求参数 + * @param secretKey 密钥 + * @return 签名字符串 + */ + fun calculateSignature(params: Map, secretKey: String): String { + val signingString = createSigningString(params) + + val finalString = "$signingString&key=$secretKey" + + return when (options.algorithm) { + SignatureAlgorithm.MD5 -> md5(finalString) + SignatureAlgorithm.SHA1 -> sha1(finalString) + SignatureAlgorithm.SHA256 -> sha256(finalString) + SignatureAlgorithm.HMAC_SHA256 -> hmacSha256(finalString, secretKey) + } + } + + /** + * 创建用于签名的规范化字符串 + * @param params 请求参数 + * @return 按键名排序并拼接的字符串 + */ + fun createSigningString(params: Map): String { + return params.entries.filter { it.key != options.signatureName }.sortedBy { it.key } + .joinToString("&") { (key, value) -> + val encodedValue = if (needsUrlEncode(value)) encodeURIComponent(value) else value + "$key=$encodedValue" + } + } + + /** + * 判断是否需要对字符串进行URL编码 + * @param s 需要判断的字符串 + * @return 如果包含非字母数字字符,返回true,否则返回false + */ + private fun needsUrlEncode(s: String): Boolean { + return !s.matches(Regex("^[a-zA-Z0-9]*$")) + } + + /** + * 验证签名 + * @param params 所有请求参数,包括签名 + * @param secretKey 密钥 + * @param maxAgeMs 允许的最大时间差(毫秒) + * @return 验证结果 + */ + fun verifySignature( + params: Map, secretKey: String, maxAgeMs: Long = 300000 + ): VerifyResult { + if (!params.containsKey(options.keyName)) { + return VerifyResult(false, "缺少参数: ${options.keyName}") + } + + if (!params.containsKey(options.channelIdName)) { + return VerifyResult(false, "缺少参数: ${options.channelIdName}") + } + + val timestampStr = + params[options.timestampName] ?: return VerifyResult(false, "缺少参数: ${options.timestampName}") + + val timestamp = timestampStr.toLongOrNull() ?: return VerifyResult(false, "无效的时间戳") + + val now = getTimestamp() + if (abs(now - timestamp) > maxAgeMs) { + return VerifyResult(false, "时间戳过期") + } + + if (!params.containsKey(options.nonceName)) { + return VerifyResult(false, "缺少参数: ${options.nonceName}") + } + + val providedSignature = + params[options.signatureName] ?: return VerifyResult(false, "缺少参数: ${options.signatureName}") + + val expectedSignature = calculateSignature(params, secretKey) + + return if (expectedSignature.equals(providedSignature, ignoreCase = true)) { + VerifyResult(true) + } else { + VerifyResult(false, "签名不匹配") + } + } + + // 签名算法实现 + + private fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return bytesToHex(md.digest(input.toByteArray())) + } + + private fun sha1(input: String): String { + val md = MessageDigest.getInstance("SHA-1") + return bytesToHex(md.digest(input.toByteArray())) + } + + private fun sha256(input: String): String { + val md = MessageDigest.getInstance("SHA-256") + return bytesToHex(md.digest(input.toByteArray())) + } + + private fun hmacSha256(input: String, key: String): String { + val mac = Mac.getInstance("HmacSHA256") + val secretKeySpec = SecretKeySpec(key.toByteArray(), "HmacSHA256") + mac.init(secretKeySpec) + return bytesToHex(mac.doFinal(input.toByteArray())) + } + + private fun bytesToHex(bytes: ByteArray): String { + val hexArray = "0123456789abcdef".toCharArray() + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = hexArray[v ushr 4] + hexChars[j * 2 + 1] = hexArray[v and 0x0F] + } + return String(hexChars) + } +} + +/** + * 验证结果 + */ +data class VerifyResult(val valid: Boolean, val error: String? = null) \ No newline at end of file diff --git a/kotlin/src/main/kotlin/com/soundforce/apisign/Main.kt b/kotlin/src/main/kotlin/com/soundforce/apisign/Main.kt new file mode 100644 index 0000000..78fab5a --- /dev/null +++ b/kotlin/src/main/kotlin/com/soundforce/apisign/Main.kt @@ -0,0 +1,151 @@ +package com.soundforce.apisign + +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlinx.cli.default +import kotlinx.cli.multiple +import java.io.File +import java.util.* + +/** + * 主程序入口 + */ +fun main(args: Array) { + try { + val envFile = File(".env") + if (envFile.exists()) { + val properties = Properties() + properties.load(envFile.inputStream()) + properties.forEach { (key, value) -> + if (System.getenv(key.toString()) == null) { + System.setProperty(key.toString(), value.toString()) + } + } + } + } catch (e: Exception) { + println("Warning: Failed to load .env file. ${e.message}") + } + + val parser = ArgParser("api-signer") + + val algorithm by parser.option( + ArgType.String, + shortName = "a", + fullName = "algorithm", + description = "签名算法: MD5, SHA1, SHA256, HMAC-SHA256" + ).default(System.getenv("SIGN_ALGORITHM") ?: "MD5") + + val url by parser.option( + ArgType.String, + shortName = "u", + fullName = "url", + description = "API基础URL" + ).default(System.getenv("API_BASE_URL") ?: "https://api.example.com/v1/data") + + val params by parser.option( + ArgType.String, + shortName = "p", + fullName = "param", + description = "请求参数,格式为key=value" + ).multiple() + + val accessKeyId by parser.option( + ArgType.String, + shortName = "k", + fullName = "key", + description = "访问密钥ID" + ).default(System.getenv("ACCESS_KEY_ID") ?: "test-access-key-id") + + val secretKey by parser.option( + ArgType.String, + shortName = "s", + fullName = "secret", + description = "密钥" + ).default(System.getenv("SECRET_KEY") ?: "test-secret-key") + + val channelId by parser.option( + ArgType.String, + shortName = "c", + fullName = "channel", + description = "合作渠道方ID" + ).default(System.getenv("CHANNEL_ID") ?: "test-channel-id") + + parser.parse(args) + + val signAlgorithm = try { + SignatureAlgorithm.fromString(algorithm) + } catch (e: IllegalArgumentException) { + println("Warning: ${e.message}, using MD5 as default") + SignatureAlgorithm.MD5 + } + + val signer = ApiSigner(SignOptions(algorithm = signAlgorithm, signatureName = "sign")) + + val requestParams = mutableMapOf() + for (param in params) { + val parts = param.split("=", limit = 2) + if (parts.size == 2) { + requestParams[parts[0]] = parts[1] + } + } + + if (requestParams.isEmpty()) { + requestParams["userId"] = "12345" + requestParams["action"] = "getData" + requestParams["data"] = "测试数据" + } + + println("===================== API签名示例 =====================") + println("AccessKeyId: $accessKeyId") + println("ChannelId: $channelId") + println("SecretKey: $secretKey") + println("签名算法: $signAlgorithm") + println("基础URL: $url") + println("请求参数:") + requestParams.forEach { (key, value) -> + println(" $key: $value") + } + + val signedUrl = signer.signUrl(url, requestParams, accessKeyId, secretKey, channelId) + println("\n签名后的URL:") + println(signedUrl) + + val signedParams = signer.signRequest(requestParams, accessKeyId, secretKey, channelId) + println("\n签名后的参数:") + signedParams.forEach { (key, value) -> + println(" $key: $value") + } + + demonstrateAlgorithms(requestParams, accessKeyId, secretKey, channelId) +} + +/** + * 演示不同算法的签名结果 + */ +private fun demonstrateAlgorithms( + params: Map, + accessKeyId: String, + secretKey: String, + channelId: String +) { + println("\n不同算法的签名结果:") + + val algorithms = listOf( + SignatureAlgorithm.MD5, + SignatureAlgorithm.SHA1, + SignatureAlgorithm.SHA256, + SignatureAlgorithm.HMAC_SHA256 + ) + + for (alg in algorithms) { + val options = SignOptions(algorithm = alg) + val signer = ApiSigner(options) + + val signParams = params.toMutableMap() + signParams["AccessKeyId"] = accessKeyId + signParams["channelId"] = channelId + + val signature = signer.calculateSignature(signParams, secretKey) + println(" $alg: $signature") + } +} \ No newline at end of file diff --git a/kotlin/src/main/kotlin/com/soundforce/apisign/SignOptions.kt b/kotlin/src/main/kotlin/com/soundforce/apisign/SignOptions.kt new file mode 100644 index 0000000..d051ca4 --- /dev/null +++ b/kotlin/src/main/kotlin/com/soundforce/apisign/SignOptions.kt @@ -0,0 +1,20 @@ +package com.soundforce.apisign + +/** + * 签名选项 + * + * @property algorithm 签名算法 + * @property keyName AccessKeyId参数名 + * @property channelIdName 合作渠道方ID参数名 + * @property timestampName 时间戳参数名 + * @property nonceName 随机字符串参数名 + * @property signatureName 签名参数名 + */ +data class SignOptions( + val algorithm: SignatureAlgorithm = SignatureAlgorithm.MD5, + val keyName: String = "AccessKeyId", + val channelIdName: String = "channelId", + val timestampName: String = "timestamp", + val nonceName: String = "nonce", + val signatureName: String = "sign" +) \ No newline at end of file diff --git a/kotlin/src/main/kotlin/com/soundforce/apisign/SignatureAlgorithm.kt b/kotlin/src/main/kotlin/com/soundforce/apisign/SignatureAlgorithm.kt new file mode 100644 index 0000000..a16b35e --- /dev/null +++ b/kotlin/src/main/kotlin/com/soundforce/apisign/SignatureAlgorithm.kt @@ -0,0 +1,42 @@ +package com.soundforce.apisign + +/** + * 签名算法类型 + */ +enum class SignatureAlgorithm { + /** MD5算法(默认、最快) */ + MD5, + + /** SHA1算法 */ + SHA1, + + /** SHA256算法 */ + SHA256, + + /** HMAC-SHA256算法(最安全) */ + HMAC_SHA256; + + override fun toString(): String { + return when (this) { + MD5 -> "MD5" + SHA1 -> "SHA1" + SHA256 -> "SHA256" + HMAC_SHA256 -> "HMAC-SHA256" + } + } + + companion object { + /** + * 从字符串解析算法类型 + */ + fun fromString(value: String): SignatureAlgorithm { + return when (value.uppercase()) { + "MD5" -> MD5 + "SHA1" -> SHA1 + "SHA256" -> SHA256 + "HMAC_SHA256", "HMACSHA256", "HMAC-SHA256" -> HMAC_SHA256 + else -> throw IllegalArgumentException("无效的签名算法: $value") + } + } + } +} \ No newline at end of file