Skip to content

Commit 898d585

Browse files
okuekazuki-ma
authored andcommitted
ISSUE-410 Add utility parser function for webhook event (#411)
* add line-bot-parser module * let LineBotCallbackRequestParser use WebhookParser * fix debug log message in WebhookParser * reformat settings.gradle * add copyright * keep LineBotCallbackRequestParser#handle(String, String) as Deprecated method * add SignatureValidator interface * fix SignatureValidator mock and its tests * add test for deprecated method
1 parent dfd4914 commit 898d585

File tree

13 files changed

+330
-51
lines changed

13 files changed

+330
-51
lines changed

line-bot-api-client/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
dependencies {
1818
compile project(':line-bot-model')
19+
implementation project(':line-bot-parser')
1920
compile 'com.fasterxml.jackson.core:jackson-core'
2021
compile 'com.fasterxml.jackson.core:jackson-databind'
2122
compile 'org.slf4j:slf4j-api'

line-bot-api-client/src/main/java/com/linecorp/bot/client/LineSignatureValidator.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,22 @@
2424
import javax.crypto.Mac;
2525
import javax.crypto.spec.SecretKeySpec;
2626

27+
import com.linecorp.bot.parser.SignatureValidator;
28+
2729
import lombok.NonNull;
2830

2931
/**
3032
* This class validates value of the `X-LINE-Signature` header.
3133
*/
32-
public class LineSignatureValidator {
34+
public class LineSignatureValidator implements SignatureValidator {
3335
private static final String HASH_ALGORITHM = "HmacSHA256";
3436
private final SecretKeySpec secretKeySpec;
3537

3638
/**
3739
* Create new instance with channel secret.
3840
*/
3941
public LineSignatureValidator(byte[] channelSecret) {
40-
this.secretKeySpec = new SecretKeySpec(channelSecret, HASH_ALGORITHM);
42+
secretKeySpec = new SecretKeySpec(channelSecret, HASH_ALGORITHM);
4143
}
4244

4345
/**
@@ -48,6 +50,7 @@ public LineSignatureValidator(byte[] channelSecret) {
4850
*
4951
* @return True if headerSignature matches signature of the content. False otherwise.
5052
*/
53+
@Override
5154
public boolean validateSignature(@NonNull byte[] content, @NonNull String headerSignature) {
5255
final byte[] signature = generateSignature(content);
5356
final byte[] decodeHeaderSignature = Base64.getDecoder().decode(headerSignature);
@@ -63,7 +66,7 @@ public boolean validateSignature(@NonNull byte[] content, @NonNull String header
6366
*/
6467
public byte[] generateSignature(@NonNull byte[] content) {
6568
try {
66-
Mac mac = Mac.getInstance(HASH_ALGORITHM);
69+
final Mac mac = Mac.getInstance(HASH_ALGORITHM);
6770
mac.init(secretKeySpec);
6871
return mac.doFinal(content);
6972
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
@@ -76,4 +79,3 @@ public byte[] generateSignature(@NonNull byte[] content) {
7679
}
7780

7881
}
79-

line-bot-parser/build.gradle

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2019 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
dependencies {
18+
implementation project(':line-bot-model')
19+
implementation 'com.fasterxml.jackson.core:jackson-databind'
20+
implementation 'org.slf4j:slf4j-api'
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2019 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.bot.parser;
18+
19+
public interface SignatureValidator {
20+
boolean validateSignature(byte[] content, String headerSignature);
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2019 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.bot.parser;
18+
19+
public class WebhookParseException extends Exception {
20+
private static final long serialVersionUID = 3026745517844618607L;
21+
22+
public WebhookParseException(String message) {
23+
super(message);
24+
}
25+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2019 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.bot.parser;
18+
19+
import java.io.IOException;
20+
import java.nio.charset.StandardCharsets;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
24+
import com.linecorp.bot.model.event.CallbackRequest;
25+
import com.linecorp.bot.model.objectmapper.ModelObjectMapper;
26+
27+
import lombok.NonNull;
28+
import lombok.extern.slf4j.Slf4j;
29+
30+
@Slf4j
31+
public class WebhookParser {
32+
private final ObjectMapper objectMapper = ModelObjectMapper.createNewObjectMapper();
33+
private final SignatureValidator signatureValidator;
34+
35+
/**
36+
* Creates a new instance.
37+
*
38+
* @param signatureValidator LINE messaging API's signature validator
39+
*/
40+
public WebhookParser(@NonNull SignatureValidator signatureValidator) {
41+
this.signatureValidator = signatureValidator;
42+
}
43+
44+
/**
45+
* Parses a request.
46+
*
47+
* @param signature X-Line-Signature header.
48+
* @param payload Request body.
49+
*
50+
* @return Parsed result. If there's an error, this method sends response.
51+
*
52+
* @throws WebhookParseException There's an error around signature.
53+
*/
54+
public CallbackRequest handle(String signature, byte[] payload) throws IOException, WebhookParseException {
55+
// validate signature
56+
if (signature == null || signature.isEmpty()) {
57+
throw new WebhookParseException("Missing 'X-Line-Signature' header");
58+
}
59+
60+
if (log.isDebugEnabled()) {
61+
log.debug("got: {}", new String(payload, StandardCharsets.UTF_8));
62+
}
63+
64+
if (!signatureValidator.validateSignature(payload, signature)) {
65+
throw new WebhookParseException("Invalid API signature");
66+
}
67+
68+
final CallbackRequest callbackRequest = objectMapper.readValue(payload, CallbackRequest.class);
69+
if (callbackRequest == null || callbackRequest.getEvents() == null) {
70+
throw new WebhookParseException("Invalid content");
71+
}
72+
return callbackRequest;
73+
}
74+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2019 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.bot.parser;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
21+
import static org.mockito.Mockito.when;
22+
23+
import java.io.InputStream;
24+
import java.nio.charset.StandardCharsets;
25+
import java.time.Instant;
26+
import java.util.List;
27+
28+
import org.junit.Before;
29+
import org.junit.Rule;
30+
import org.junit.Test;
31+
import org.mockito.Mock;
32+
import org.mockito.junit.MockitoJUnit;
33+
import org.mockito.junit.MockitoRule;
34+
35+
import com.google.common.io.ByteStreams;
36+
37+
import com.linecorp.bot.model.event.CallbackRequest;
38+
import com.linecorp.bot.model.event.Event;
39+
import com.linecorp.bot.model.event.MessageEvent;
40+
import com.linecorp.bot.model.event.message.TextMessageContent;
41+
42+
public class WebhookParserTest {
43+
@Rule
44+
public final MockitoRule mockitoRule = MockitoJUnit.rule();
45+
46+
@Mock
47+
private final SignatureValidator signatureValidator = new MockSignatureValidator();
48+
49+
static class MockSignatureValidator implements SignatureValidator {
50+
@Override
51+
public boolean validateSignature(byte[] content, String headerSignature) {
52+
return false;
53+
}
54+
}
55+
56+
private WebhookParser parser;
57+
58+
@Before
59+
public void before() {
60+
parser = new WebhookParser(signatureValidator);
61+
}
62+
63+
@Test
64+
public void testMissingHeader() {
65+
assertThatThrownBy(() -> parser.handle("", "".getBytes(StandardCharsets.UTF_8)))
66+
.isInstanceOf(WebhookParseException.class)
67+
.hasMessage("Missing 'X-Line-Signature' header");
68+
}
69+
70+
@Test
71+
public void testInvalidSignature() {
72+
assertThatThrownBy(
73+
() -> parser.handle("SSSSIGNATURE", "{}".getBytes(StandardCharsets.UTF_8)))
74+
.isInstanceOf(WebhookParseException.class)
75+
.hasMessage("Invalid API signature");
76+
}
77+
78+
@Test
79+
public void testNullRequest() {
80+
final String signature = "SSSSIGNATURE";
81+
final byte[] nullContent = "null".getBytes(StandardCharsets.UTF_8);
82+
83+
when(signatureValidator.validateSignature(nullContent, signature)).thenReturn(true);
84+
85+
assertThatThrownBy(() -> parser.handle(signature, nullContent))
86+
.isInstanceOf(WebhookParseException.class)
87+
.hasMessage("Invalid content");
88+
}
89+
90+
@Test
91+
public void testCallRequest() throws Exception {
92+
final InputStream resource = getClass().getClassLoader().getResourceAsStream(
93+
"callback-request.json");
94+
final byte[] payload = ByteStreams.toByteArray(resource);
95+
96+
when(signatureValidator.validateSignature(payload, "SSSSIGNATURE")).thenReturn(true);
97+
98+
final CallbackRequest callbackRequest = parser.handle("SSSSIGNATURE", payload);
99+
100+
assertThat(callbackRequest).isNotNull();
101+
102+
final List<Event> result = callbackRequest.getEvents();
103+
104+
final MessageEvent messageEvent = (MessageEvent) result.get(0);
105+
final TextMessageContent text = (TextMessageContent) messageEvent.getMessage();
106+
assertThat(text.getText()).isEqualTo("Hello, world");
107+
108+
final String followedUserId = messageEvent.getSource().getUserId();
109+
assertThat(followedUserId).isEqualTo("u206d25c2ea6bd87c17655609a1c37cb8");
110+
assertThat(messageEvent.getTimestamp()).isEqualTo(
111+
Instant.parse("2016-05-07T13:57:59.859Z"));
112+
}
113+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"destination": "U00000000000000000000000000000000",
3+
"events": [
4+
{
5+
"replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
6+
"type": "message",
7+
"timestamp": 1462629479859,
8+
"source": {
9+
"type": "user",
10+
"userId": "u206d25c2ea6bd87c17655609a1c37cb8"
11+
},
12+
"message": {
13+
"id": "325708",
14+
"type": "text",
15+
"text": "Hello, world"
16+
}
17+
},
18+
{
19+
"replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
20+
"type": "follow",
21+
"timestamp": 1462629479859,
22+
"source": {
23+
"type": "user",
24+
"userId": "u206d25c2ea6bd87c17655609a1c37cb8"
25+
}
26+
}
27+
]
28+
}

line-bot-servlet/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
dependencies {
1818
compile project(':line-bot-api-client')
19+
implementation project(':line-bot-parser')
1920
compile 'com.fasterxml.jackson.core:jackson-databind'
2021
compile 'com.google.guava:guava'
2122

0 commit comments

Comments
 (0)