Slack 请求验证:无法使用签名密钥计算匹配的请求摘要

Posted

技术标签:

【中文标题】Slack 请求验证:无法使用签名密钥计算匹配的请求摘要【英文标题】:Slack request verification: Can't compute matching request digest using signed secret 【发布时间】:2018-12-18 06:50:26 【问题描述】:

我正在 Slack 上实现交互式消息,其中包含一些操作按钮。使用Slack App,我可以处理 Slack 用户单击我的 Java Springboot API 上的按钮。

到目前为止,一切都很好。但是,我很难计算匹配的请求签名(摘要)来验证它实际上来自 Slack。我在Slack verification documentation page 上阅读了所有相关文档。

该页面描述,必须将签名计算为HMAC SHA256 哈希,使用签名秘密作为密钥,内容作为松弛版本、时间戳和请求正文的串联,例如:

v0:123456789:command=/weather&text=94070

页面上写着:

...计算签名时仅评估原始 HTTP 请求正文。

...所以我没有在哈希计算之前对请求进行编码/反序列化(我在下面附上了我从 Slack 收到的请求)

为了计算哈希,我使用*** 上的代码:

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";
    

【问题讨论】:

我很高兴看到我不是唯一一个有问题的人。我一直在与 Slack 支持人员讨论这个问题,我们验证了我的 HMAC 实现是好的,我再也看不出问题出在哪里了。如果这是一个特别新的功能,也许他们有问题?无论如何,也许尝试写信来支持,因为您的实现在我看来通常没问题。 @ChristopherOrr 是的,我也写信给 Slack 支持,他们很快就会看到 【参考方案1】:

我也被这个咬了。正如@andrew-bruce 还指出的那样,使用@RequestBody 并不会让你回到原来的身体。特别是对我来说,它在原来的%2A 上失败了,当得到这样的身体时,它最终变成了一个未编码的*。显然验证失败。

我最终得到了这个解决方案:

    一个过滤器与一个允许多次读取正文的 HttpServletRequest 包装器相结合:
import org.springframework.web.filter.OncePerRequestFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * To verify if requests are coming from Slack we need to implement this:
 * https://api.slack.com/authentication/verifying-requests-from-slack. Luckily the Bolt framework already implements
 * this for us, however we need to provide it with a body that is unaltered. Somewhere in Springs filterchain Spring
 * will already have consumed the [HttpServletRequest#inputstream], so we cannot get it from the [HttpServletRequest]
 * directly. Spring obviously provides a [org.springframework.web.bind.annotation.RequestBody] annotation, but this is 
 * slightly different from the original body. This servlet filter will be put as the very first in
 * the chain (see [SlackConfig#multiReadRequestFilter] should make sure
 * that we can re-read it and construct the raw body, so that the verification doesn't fail.
 */
class MultiReadHttpServletFilter : OncePerRequestFilter() 

    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) 
        val multiReadHttpServletRequest = MultiReadHttpServletRequest(request)

        filterChain.doFilter(multiReadHttpServletRequest, response)
    


import org.apache.commons.io.IOUtils
import java.io.ByteArrayInputStream
import java.io.IOException
import javax.servlet.ReadListener
import javax.servlet.ServletInputStream
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletRequestWrapper


class MultiReadHttpServletRequest(request: HttpServletRequest) : HttpServletRequestWrapper(request) 

    private var body: ByteArray = IOUtils.toByteArray(request.inputStream)

    @Throws(IOException::class)
    override fun getInputStream(): ServletInputStream 
        return object : ServletInputStream() 
            val bais = ByteArrayInputStream(body)

            override fun isReady(): Boolean = true

            override fun isFinished(): Boolean = bais.available() == 0

            override fun read(): Int = bais.read()

            override fun setReadListener(readListener: ReadListener) 
                throw NotImplementedError("Not implemented!")
            
        
    

    将其配置为链中的第一个。因为这个过滤器实际上消耗了内存中的东西,所以我专门针对特定路径,所以在这种情况下它只针对传入的 Slack 事件。
    @Bean
    fun multiReadRequestFilter(): FilterRegistrationBean<MultiReadHttpServletFilter> 
        // this needs to match the path(s) of the controller SlackAppController
        val urlPatterns = slacks.allByKey().keys.map  "/v2/$it/slack/events" .toTypedArray()

        val registrationBean = FilterRegistrationBean<MultiReadHttpServletFilter>()
        registrationBean.filter = MultiReadHttpServletFilter()
        registrationBean.addUrlPatterns(*urlPatterns)
        registrationBean.order = Ordered.HIGHEST_PRECEDENCE

        return registrationBean
    
    现在我可以使用它来检索“真实”的原始身体:
@RestController
@RequestMapping("/v2/slackId/slack/events")
class SlackAppController() 

    // ...

    @PostMapping
    fun handle(
            @PathVariable("slackId") slackId: String,
            httpServletRequest: HttpServletRequest,
            @RequestParam queryStringParams: MultiValueMap<String, String>,
            @RequestHeader headers: MultiValueMap<String, String>): ResponseEntity<*> 

        val body = IOUtils.toString(httpServletRequest.inputStream, StandardCharsets.UTF_8)

        // ...
    

【讨论】:

【参考方案2】:

这是我在该主题上发现的:

Spring MVC 将读取请求的主体并返回一个不同的主体,其中参数的顺序发生了变化。读取部分通常发生在链上的第一个过滤器中HiddenHttpMethodFilter,这是我签名验证失败的主要原因。 这里的请求被错误地“重构”ServletServerHttpRequest我不确定这是否应该作为一个错误提交,但它肯定是一团糟。 如果您使用注入的 HttpEntity&lt;String&gt;@RequestBody String body,您将收到错误的正文,不是原始内容,而是“重构”的正文

现在是解决方案

    创建一个过滤器来验证 Slack 签名并将其注册为最高优先级,使其位于过滤器链的顶部:
@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)。

【讨论】:

【参考方案3】:

我们只是偶然发现了完全相同的问题。 您关于星号解码的提示对我们帮助很大! 我不知道你是否已经解决了缓存请求的问题,但也许你想看看我们的开源 SlackBot SDK for Spring boot,我们能够解决这个问题: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 之前,缓存是空的。因此,在您使用请求之后验证签名非常重要。

【讨论】:

【参考方案4】:

我遇到了同样的问题,使用 Spring 的 @RequestBody

在 Slack 和我的 Spring 应用程序之间设置mitmproxy 以比较请求主体之后,结果证明 Spring 正在解码例如星号字符,而不是将它们保留为 %2A

我的解决方法是切换到请求 [HttpServletRequest](https://github.com/boclips/terry/commit/c51382a5a6a9e96d5b19e22b038654bfb19b65b0#diff-79f3c274c9fa96261f8c9e09306a088bR37) (不需要 Spring 注释)并阅读原始正文它使用 `request.reader.use it.readText() `(使用 Kotlin 的 `use` 在阅读后关闭阅读器对象)。

编辑:上述技术不起作用,从 Spring 获取原始请求正文本身就是一项任务!进行中。

【讨论】:

【参考方案5】:

我在 Node.js 实现中偶然发现了同样的问题,并发现了这个 Medium article,它说明了以下内容:

注意:我们不能使用 Node 自带的 querystring 包,因为它只支持 RFC3986 空间编码,而 Slack 需要我们实现 RFC1738 空间编码。

这两种编码有什么区别?解析空格的方式:

RFC3986 会将" " 转换为"%20" RFC1738 会将" " 转换为"+"

对于 Node.js,建议安装 qs 并像这样使用它:

qs.stringify(req.body, format : 'RFC1738' );

【讨论】:

我在验证来自调用请求的签名时遇到问题,这是因为一个库(typeorm)转换了初始请求,将其添加到我的中间件解决了这个问题。非常感谢。【参考方案6】:

我遇到了同样的问题,在我的控制器中,我以Map 接收请求的主体,我收到了所有值,但是当我计算哈希时,我看到松弛签名和我的哈希是不一样。

我尝试像@Stefan 解决方案一样以String 的形式接收请求正文,这对我有用,因此,在您的控制器中使用HttpEntity&lt;String&gt;,您必须以纯String 和@ 接收正文987654325@ 在您的方法参数中,原因是 slack 在请求中发送编码值 %2F%3A,带有 HttpEntityMap,spring 将这些值解释为 /:是您的哈希不等于 slack 签名的原因。

希望对您有所帮助。

【讨论】:

【参考方案7】:

以下对我们有用:

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 文档中的步骤进行操作,效果很好。

【讨论】:

不太确定这对您有什么作用。如果我使用您的代码,我的 POST 正文会出现错误排序的参数,并且 HMAC 计算不正确。想知道我的东西哪里出了问题...

以上是关于Slack 请求验证:无法使用签名密钥计算匹配的请求摘要的主要内容,如果未能解决你的问题,请参考以下文章

收到 IDX10501 的错误信息:签名验证失败。使用 Azure AD 时无法匹配密钥

AWS S3 - 我们计算的请求签名与您提供的签名不匹配。检查您的密钥和签名方法

我们计算的请求签名与您提供的签名不匹配。检查您的 AWS 秘密访问密钥和签名方法

解析请求体禁止请求签名验证

支付宝签名验证实现-Delphi版

iOS https请求对自签名证书忽略