Typeform是一家制作线上调查问卷的公司。
Muñoz 和 David Okuniev两人于2012年创作出一个更加动态、更具交互性的用户调查工具,每次只提一个问题,并且根据用户的回答为其呈现下一个问题,像和朋友间的对话一样,让用户在不知不觉中就完成了问卷。
Typeform将帮你获得有关产品和经验的反馈,创建和分享反馈、建立联系人表单,进行客户开发调查,结果将以交互式表格的形式快速发送到你的智能手机、平板电脑和台式电脑上。
spring.security 配置不拦截的url.
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/auth/**", "/actuator/health", "/satisfaction/callback");
}
}
注册Bean进行全局的url拦截。
@Bean
public TypeformFilter typeformContentCacheFilter() {
return new TypeformFilter();
}
拦截Type Form的url,然后进行 HmacSha256Signature 签名校验。
注意: filterChain.doFilter(servletRequestWrapper, httpServletResponse),由于HttpServletRequest去读一次后就会释放掉资源,所以需要把请求报文缓存起来。
@Slf4j
@Order(value = Ordered.HIGHEST_PRECEDENCE)
public class TypeformFilter extends OncePerRequestFilter {
public static final String TYPEFORM_SATISFACTION_CALLBACKS = "/satisfaction/callback";
@Value("${typeform.sha.key}")
private String key;
@Override
public void destroy() {
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest httpServletRequest,
@NonNull HttpServletResponse httpServletResponse,
@NonNull FilterChain filterChain) throws ServletException, IOException {
CachedBodyHttpServletRequest servletRequestWrapper = new CachedBodyHttpServletRequest(
httpServletRequest);
String requestUri = servletRequestWrapper.getRequestURI();
if (requestUri.equals(TYPEFORM_SATISFACTION_CALLBACKS)) {
String signature = servletRequestWrapper.getHeader("Typeform-Signature");
String payload = servletRequestWrapper.read();
if (!validateHmacSha256Signature(signature, payload, key)) {
httpError(httpServletResponse);
return;
}
}
filterChain.doFilter(servletRequestWrapper, httpServletResponse);
}
private static boolean validateHmacSha256Signature(String signature, String payload, String key) {
return getHmacSha256(payload, key).equals(signature);
}
private static String getHmacSha256(String payload, String key) {
return "sha256=" + HashUtil.hmacWithAlgorithm("HmacSHA256", payload, key);
}
public static void httpError(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
out.print("Invalid Request");
out.flush();
out.close();
}
}
hmac算法的工具类,通过算法名称,请求报文,签名密钥获取加密后的报文。
@Slf4j
public class HashUtil {
public static String hmacWithAlgorithm(String algorithm, String payload, String key) {
try {
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), algorithm);
Mac mac = Mac.getInstance(algorithm);
mac.init(secretKeySpec);
return Base64.getEncoder().encodeToString((mac.doFinal(payload.getBytes())));
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("hmacWithAlgorithm error", e);
return null;
}
}
}
CachedBodyHttpServletRequest 缓存了请求报文,以便后续拦截器过滤使用。
缓存代码:this.requestBody = StreamUtils.copyToByteArray(is);
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] requestBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
try (InputStream is = request.getInputStream();) {
this.requestBody = StreamUtils.copyToByteArray(is);
}
}
@Override
public ServletInputStream getInputStream() {
return new ServletInputStreamWrapper(this.requestBody);
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(this.requestBody)));
}
public String read() throws IOException {
BufferedReader reader = this.getReader();
String line;
StringBuilder payloadBuilder = new StringBuilder();
while ((line = reader.readLine()) != null) {
payloadBuilder.append(line).append(System.lineSeparator());
}
return payloadBuilder.toString();
}
}
@Override
public ServletInputStream getInputStream() {
return new ServletInputStreamWrapper(this.requestBody);
}
因为这里需要返回 ServletInputStream所以还需要包装一个类,因此有了ServletInputStreamWrapper。
ServletInputStreamWrapper 包装了inputStream类,以便CachedBodyHttpServletRequest进行读取。
package com.veoride.mech.api.component;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import lombok.extern.slf4j.Slf4j;
/**
* @author darmi
*/
@Slf4j
public class ServletInputStreamWrapper extends ServletInputStream {
public static final int REACH_END = 0;
private final InputStream inputStream;
public ServletInputStreamWrapper(byte[] cachedBody) {
this.inputStream = new ByteArrayInputStream(cachedBody);
}
@Override
public boolean isFinished() {
try {
return inputStream.available() == REACH_END;
} catch (IOException e) {
log.error("isFinished fail", e);
}
return false;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return inputStream.read();
}
}
根据自己需要完成业务代码编写。
@RestController
@RequestMapping
@Slf4j
public class Controller {
@PostMapping("/satisfaction/callback")
public void satisfactionCallback(@RequestBody @Valid WebhookDTO webhookDTO) {
...
}
}
package com.veoride.mech.api.dto.typeform;
import com.fasterxml.jackson.annotation.JsonAlias;
import javax.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* @author darmi
*/
@Getter
@Setter
@ToString
public class WebhookDTO {
@JsonAlias("form_response")
@NotNull
private FormResponse formResponse;
public Long getPhone() {
return this.formResponse.getHidden().getPhone();
}
@Getter
@Setter
@ToString
public static class FormResponse {
@NotNull
private Hidden hidden;
}
@Getter
@Setter
@ToString
public static class Hidden {
@NotNull
private Long phone;
}
}