我正在Slack上实现交互式消息,它包含一些动作按钮。使用Slack App我能够处理Slack用户点击我的Java Springboot API上的按钮。
到目前为止,一切都很好。但是,我很难计算匹配请求签名(摘要)以验证它实际来自Slack。我在Slack verification documentation page上阅读了有关该文档的所有文档。
该页面描述了签名必须计算为HMAC SHA256哈希,使用签名密钥作为密钥,内容作为松弛版本,时间戳和请求体的串联,例如:
v0:123456789:command=/weather&text=94070
在页面上说明:
...在计算签名时仅评估原始HTTP请求正文。
...所以我没有在哈希计算之前编码/反序列化请求(我已经从Slack下面收到了我收到的请求)
要计算哈希,我使用StackOverflow上的代码:
private String computeMessageDigest(String content) {
final String ALGORITHM = "HmacSHA256";
final String UTF_8 = "UTF-8";
try {
Key signingKey = new SecretKeySpec(signingSecret.getBytes(UTF_8), ALGORITHM);
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(signingKey);
return Hex.encodeHexString(mac.doFinal(content.getBytes(UTF_8)));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
我也试过这个online hash generator来比较结果,它们是一样的。
从Slack收到的请求如下:
{
"headers": {
"x-forwarded-for": ["::ffff:52.72.111.29"],
"x-forwarded-proto": ["https"],
"x-pagekite-port": ["443"],
"host": ["inqool.pagekite.me"],
"user-agent": ["Slackbot 1.0 (+https://api.slack.com/robots)"],
"accept-encoding": ["gzip,deflate"],
"accept": ["application/json,*/*"],
"x-slack-signature": ["v0=87fbffb089501ba823991cc20058df525767a8a2287b3809f9afff3e3b600dd8"],
"x-slack-request-timestamp": ["1531221943"],
"content-length": ["2731"],
"Content-Type": ["application/x-www-form-urlencoded;charset=UTF-8"]
},
"body": "payload=%7B%22type%22%3A%22interactive_message%22%2C%22actions%22%3A%5B%7B%22name%22%3A%22reject_btn%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22false%22%7D%5D%2C%22callback_id%22%3A%22artwork%3D40d7a87f-466c-4fc9-b454-09ce020d4465%22%2C%22team%22%3A%7B%22id%22%3A%22T03NP6SA7%22%2C%22domain%22%3A%22artstaq%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22G8F2WR4FJ%22%2C%22name%22%3A%22privategroup%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U66T9QX60%22%2C%22name%22%3A%22majo%22%7D%2C%22action_ts%22%3A%221531221943.512498%22%2C%22message_ts%22%3A%221531221198.000225%22%2C%22attachment_id%22%3A%221%22%2C%22token%22%3A%22ZABrZDXgJCOOLNau5mXnfNQR%22%2C%22is_app_unfurl%22%3Afalse%2C%22original_message%22%3A%7B%22text%22%3A%22User+just+put+item+on+*EXCHANGE*.%22%2C%22bot_id%22%3A%22BBM1W4QEL%22%2C%22attachments%22%3A%5B%7B%22author_name%22%3A%22Slack+Test%3B+slack%40test.com%22%2C%22callback_id%22%3A%22artwork%3D40d7a87f-466c-4fc9-b454-09ce020d4465%22%2C%22fallback%22%3A%22Slack+Test%3B+%3Cmailto%3Aslack%40test.com%7Cslack%40test.com%3E+just+put+item+Panenka+%5C%2F+Doll+by+artist+Jaroslav+Vale%5Cu010dka+into+ON+REQUEST+mode%22%2C%22text%22%3A%22%3Chttp%3A%5C%2F%5C%2Flocalhost%3A8080%5C%2Fartist%5C%2F609cd328-d533-4ab0-b982-ec2f104476f2%7CJaroslav+Vale%5Cu010dka%3E%22%2C%22title%22%3A%22Panenka+%5C%2F+Doll%22%2C%22footer%22%3A%22ARTSTAQ+Slack+Reporter%22%2C%22id%22%3A1%2C%22title_link%22%3A%22http%3A%5C%2F%5C%2Flocalhost%3A8080%5C%2Fartwork%5C%2F40d7a87f-466c-4fc9-b454-09ce020d4465%22%2C%22color%22%3A%22f0d0ad%22%2C%22fields%22%3A%5B%7B%22title%22%3A%22Trading+type%22%2C%22value%22%3A%22ON+REQUEST%22%2C%22short%22%3Atrue%7D%5D%2C%22actions%22%3A%5B%7B%22id%22%3A%221%22%2C%22name%22%3A%22approve_btn%22%2C%22text%22%3A%22APPROVE%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22true%22%2C%22style%22%3A%22primary%22%2C%22confirm%22%3A%7B%22text%22%3A%22Do+you+really+want+to+approve+this+artwork%3F%22%2C%22title%22%3A%22Approve+artwork%22%2C%22ok_text%22%3A%22Yes%22%2C%22dismiss_text%22%3A%22Cancel%22%7D%7D%2C%7B%22id%22%3A%222%22%2C%22name%22%3A%22reject_btn%22%2C%22text%22%3A%22REJECT%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22false%22%2C%22style%22%3A%22danger%22%2C%22confirm%22%3A%7B%22text%22%3A%22Do+you+really+want+to+reject+this+artwork%3F%22%2C%22title%22%3A%22Reject+artwork%22%2C%22ok_text%22%3A%22Yes%22%2C%22dismiss_text%22%3A%22Cancel%22%7D%7D%5D%7D%5D%2C%22type%22%3A%22message%22%2C%22subtype%22%3A%22bot_message%22%2C%22ts%22%3A%221531221198.000225%22%7D%2C%22response_url%22%3A%22https%3A%5C%2F%5C%2Fhooks.slack.com%5C%2Factions%5C%2FT03NP6SA7%5C%2F395760858899%5C%2FGlP9jsNQak7FqEciEHhscx4L%22%2C%22trigger_id%22%3A%22395632563524.3771230347.851ab60578de033398338a9faeb41a15%22%7D"
}
当我计算HMAC SHA256哈希时,我得到了561034bb6860c07a6b4eaf245b6da3ea869c7806c7f7be20b1a830b6d25c54c8
,但是我应该得到87fbffb089501ba823991cc20058df525767a8a2287b3809f9afff3e3b600dd8
,就像在请求头中一样。
我还尝试从URL解码的主体计算哈希值,但仍然无法获得匹配的签名。
难道我做错了什么?谢谢你的答案/提示。
编辑:这是我的REST控制器和请求验证器的完整源代码:
package com.artstaq.resource;
import com.artstaq.integration.slack.SlackRequestVerifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.inject.Inject;
@RestController
@RequestMapping("/content_admin")
public class ContentAdminResource {
private SlackRequestVerifier slackVerifier;
@RequestMapping(value = "/slack/artwork/resolve", method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public void resolve(HttpEntity<String> request) {
slackVerifier.verifySlackRequest(request);
}
@Inject
public void setSlackVerifier(SlackRequestVerifier slackVerifier) {
this.slackVerifier = slackVerifier;
}
}
package com.artstaq.integration.slack;
import com.artstaq.exception.SignatureVerificationException;
import com.artstaq.exception.TimestampTooOldException;
import org.apache.commons.codec.binary.Hex;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
/**
* Class providing request verification received from Slack
*/
@Component
public class SlackRequestVerifier {
@Value("${integration.slack.version:v0}")
private String version;
@Value("${integration.slack.signingSecret}")
private String signingSecret;
/**
* Verifies the integrity of received Slack request.
*/
public void verifySlackRequest(HttpEntity<String> request) {
String timestamp = request.getHeaders().getFirst(SlackHeaders.TIMESTAMP);
Instant timeInstant = Instant.ofEpochSecond(Long.valueOf(timestamp));
if (timeInstant.plus(5, ChronoUnit.MINUTES).compareTo(Instant.now()) < 0) {
throw new TimestampTooOldException(timeInstant);
}
String expectedDigest = request.getHeaders().getFirst(SlackHeaders.SIGNATURE);
String basestring = String.join(":", version, timestamp, request.getBody());
String computedDigest = version + "=" + computeMessageDigest(basestring);
if (!computedDigest.equals(expectedDigest)) {
throw new SignatureVerificationException(expectedDigest, computedDigest);
}
}
/**
* Compute HMAC SHA256 digest for given content using defined slack signing secret
*/
private String computeMessageDigest(String content) {
final String ALGORITHM = "HmacSHA256";
final String UTF_8 = "UTF-8";
try {
Key signingKey = new SecretKeySpec(signingSecret.getBytes(UTF_8), ALGORITHM);
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(signingKey);
return Hex.encodeHexString(mac.doFinal(content.getBytes(UTF_8)));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static class SlackHeaders {
private static final String TIMESTAMP = "X-Slack-Request-Timestamp";
private static final String SIGNATURE = "X-Slack-Signature";
}
}
以下为我们工作:
public enum SigningVerification {
VERIFIED,
DENIED
}
public SigningVerification verify(ImmutableSigningSecretRequest request) {
String basestring = String.join(":", "v0", request.timestamp(), request.body());
SecretKeySpec secret_key = new SecretKeySpec(signingSecret.getBytes(), "HmacSHA256");
Mac sha256_HMAC = Try.of(() -> Mac.getInstance("HmacSHA256")).getOrElseThrow((SupplierRuntimeException) RuntimeException::new);
Try.run(() -> sha256_HMAC.init(secret_key));
String hash = "v0=" + Hex.encodeHexString(sha256_HMAC.doFinal(basestring.getBytes()));
return hash.equals(request.verificationSignature()) ? VERIFIED : DENIED;
}
控制器:
@PostMapping("/command")
public RichMessage postCommand(@RequestHeader(value = "X-Slack-Request-Timestamp") String timestamp,
@RequestHeader(value = "X-Slack-Signature") String signature,
@RequestParam(value = "text", required = false) String message,
@RequestBody String body) {
SigningSecretVerification.SigningVerification verification = verifier.verify(ImmutableSigningSecretRequest
.builder()
.timestamp(timestamp)
.verificationSignature(signature)
.body(body)
.build()
);
return new RichMessage(message);
}
我们基本上只是遵循Slack doc中的步骤,它工作正常。
我在Node.js实现上偶然发现了同样的问题,发现这个Medium article说明了以下内容:
注意:我们不能使用内置的查询字符串Node包,因为它只支持RFC3986空间编码,而Slack要求我们实现RFC1738空间编码。
两种编码有什么区别?解析空格的方式:
" "
转换为"%20"
" "
转换为"+"
对于Node.js,它建议安装qs并像这样使用它:
qs.stringify(req.body, { format : 'RFC1738' });
我有同样的问题,使用Spring的@RequestBody
。
在设置了Slack和我的Spring应用程序之间的mitmproxy以便比较请求主体之后,事实证明Spring正在解码,例如星号字符而不是留下它们作为%2A
。
编辑:上面的技术不起作用,从Spring获取原始请求体本身就是一个任务!正在进行中。
我们只是偶然发现了同样的问题。关于星号解码的提示帮助了我们很多!我不知道你是否已经通过缓存请求解决了你的问题,但也许你想看看我们的开源SlackBot SDK for Spring启动,我们能够解决这个问题:https://github.com/kreait/slack-spring-boot-starter/blob/master/starter/slack-spring-boot/src/main/kotlin/io/olaph/slack/broker/security/VerificationMethodArgumentResolver.kt这个VerificationMethodArgumentResolver基本上收到了请求,将其包装在ContentCachingRequestWrapper中,并调用普通ArgumentResolvers的internalResolveArgument,并使用缓存的请求验证请求。这里棘手的部分是,在您请求其parameterMap之前,缓存是空的。因此,在您使用请求后验证签名非常重要。
我遇到了同样的问题,在我的控制器中,我收到了Map
请求的正文,我收到了所有的值,但是当我计算哈希时,我看到了松弛签名和我的哈希不一样。
我尝试接收请求体作为String
就像@Stefan解决方案一样对我有用,所以,在你的控制器中使用HttpEntity<String>
,你必须在你的方法参数中使用String
接收正文@RequestBody String body
,原因是slack发送请求中的编码值,%2F
或%3A
,使用HttpEntity
或Map
,spring将该值解释为/
和:
,这就是为什么你的哈希不等于松弛签名的原因。
希望这对你有所帮助。
这是我在这个主题上发现的:
HiddenHttpMethodFilter
链上的第一个过滤器中,这是我的签名验证失败的主要原因。ServletServerHttpRequest
我不确定这是否应该作为一个bug提交,但它肯定搞砸了。HttpEntity<String>
或@RequestBody String body
你将收到错误的身体,而不是原始内容,但“重建”的而现在解决方案:
@Bean
public FilterRegistrationBean<SlackVerificationFilter> slackVerificationFilterRegistrationBean() {
String path = "/slack";
FilterRegistrationBean<SlackVerificationFilter> frb = new FilterRegistrationBean<>(new SlackVerificationFilter());
frb.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
frb.setName("csrfFilter");
frb.setAsyncSupported(true);
frb.addUrlPatterns(path);
frb.setMatchAfter(false);
frb.setEnabled(true);
frb.setOrder(Ordered.HIGHEST_PRECEDENCE);
return frb;
}
HttpServletRequestWrapper
包装请求:public class SlackVerificationFilter extends GenericFilterBean {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
final BufferedRequestWrapper request = new BufferedRequestWrapper((HttpServletRequest) req);
final HttpServletResponse response = (HttpServletResponse) res;
String rawBody = IOUtils.toString(request.getInputStream(), "UTF-8");
// do signature verification here
chain.doFilter(request, response);
}
}
我不会详细介绍请求包装器。在这个网站和其他地方有很多例子。
HttpServletRequestWrapper
必须实现以下方法:public ServletInputStream getInputStream();
public BufferedReader getReader() throws IOException;
public Map<String, String[]> getParameterMap();
public String getParameter(String name);
在此之后,您应该不再有问题验证Slack签名。
在我的情况下,我没有上面提到的任何编码字符(%20
,%2A
等)的问题。我只是在验证斜杠命令请求的签名时遇到了问题。消息操作请求已正确验证,因为它们在正文中只有1个请求参数(payload
)。