Skip to content

Commit

Permalink
Add controller used to handle Github webhooks
Browse files Browse the repository at this point in the history
This webhook in turn triggers a repository dispatch on the github actions
workflow used to build the site.
  • Loading branch information
mbhave committed Mar 31, 2023
1 parent 58818b0 commit 0c3c141
Show file tree
Hide file tree
Showing 12 changed files with 800 additions and 5 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies {
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets'
implementation 'javax.xml.bind:jaxb-api:2.1'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Expand Down
72 changes: 67 additions & 5 deletions src/main/java/io/spring/renderer/RendererProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ public static class Github {
@Pattern(regexp = "([0-9a-zA-Z_]*)?")
private String token;

/**
* Name of the Github organization to fetch guides from.
*/
private String organization = "spring-guides";

private Webhook webhook = new Webhook();

public String getToken() {
return this.token;
}
Expand All @@ -51,11 +58,6 @@ public void setToken(String token) {
this.token = token;
}

/**
* Name of the Github organization to fetch guides from.
*/
private String organization = "spring-guides";

public String getOrganization() {
return this.organization;
}
Expand All @@ -64,6 +66,66 @@ public void setOrganization(String organization) {
this.organization = organization;
}

public Webhook getWebhook() {
return this.webhook;
}

}

public static class Webhook {

/**
* Token configured in GitHub webhooks for this application.
*/
private String secret = "changeme";

/**
* Org name for dispatching Github Action.
*/
private String actionOrg;

/**
* Repository name for dispatching Github Action.
*/
private String actionRepo;

/**
* Token with repo scope for for dispatching Github Action.
*/
private String dispatchToken;

public String getSecret() {
return this.secret;
}

public void setSecret(String secret) {
this.secret = secret;
}

public String getActionOrg() {
return this.actionOrg;
}

public void setActionOrg(String actionOrg) {
this.actionOrg = actionOrg;
}

public String getActionRepo() {
return this.actionRepo;
}

public void setActionRepo(String actionRepo) {
this.actionRepo = actionRepo;
}

public String getDispatchToken() {
return this.dispatchToken;
}

public void setDispatchToken(String dispatchToken) {
this.dispatchToken = dispatchToken;
}

}

}
42 changes: 42 additions & 0 deletions src/main/java/io/spring/renderer/SecurityConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2022-2023 the original author or 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.
*/
package io.spring.renderer;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

/**
* Security configuration for the application.
*
* @author Madhura Bhave
*/
@Configuration
public class SecurityConfiguration {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((request) -> {
request.mvcMatchers("/webhook/guides").permitAll();
request.anyRequest().authenticated();
});
http.csrf(csrf -> csrf.ignoringAntMatchers("/webhook/**"));
http.httpBasic();
return http.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2022-2023 the original author or 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.
*/

package io.spring.renderer.guides.webhook;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

/**
* Service to connect to Github Actions.
*
* @author Madhura Bhave
*/
@Service
class GithubActionsService {

private final RestTemplate restTemplate;

private static final String ACCEPT_HEADER = "application/vnd.github+json";

private static final String DISPATCH_PATH_TEMPLATE = "https://api.github.com/repos/{org}/{repo}/dispatches";

GithubActionsService(RestTemplateBuilder builder) {
this.restTemplate = builder.build();
}

void triggerRespositoryDispatch(String org, String repo, String token) {
RequestEntity<String> entity = RequestEntity.post(DISPATCH_PATH_TEMPLATE, org, repo)
.header("Authorization", "Bearer " + token).header("Accept", ACCEPT_HEADER)
.body("{\"event_type\": \"guides\"}");
try {
this.restTemplate.exchange(entity, Void.class);
}
catch (HttpClientErrorException ex) {
throw new RepositoryDispatchFailedException(ex.getRawStatusCode());
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright 2022-2023 the original author or 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.
*/

package io.spring.renderer.guides.webhook;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.renderer.RendererProperties;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Controller that handles requests from GitHub webhook set up at
* <a href="https://github.com/spring-guides/">the org level </a> and triggers the Github
* Action to update the website. Github requests are signed with a shared secret, using an
* HMAC sha-1 algorithm.
*/
@RestController
@RequestMapping("/webhook/")
class GuidesWebhookController {

private static final Log logger = LogFactory.getLog(GuidesWebhookController.class);

private static final Charset CHARSET = StandardCharsets.UTF_8;

private static final String HMAC_ALGORITHM = "HmacSHA1";

private static final String PING_EVENT = "ping";

private final ObjectMapper objectMapper;

private final Mac hmac;

private final GithubActionsService service;

private final RendererProperties properties;

@Autowired
public GuidesWebhookController(ObjectMapper objectMapper, RendererProperties properties,
GithubActionsService service) throws NoSuchAlgorithmException, InvalidKeyException {
this.objectMapper = objectMapper;
this.service = service;
// initialize HMAC with SHA1 algorithm and secret
SecretKeySpec secret = new SecretKeySpec(properties.getGithub().getWebhook().getSecret().getBytes(CHARSET),
HMAC_ALGORITHM);
this.hmac = Mac.getInstance(HMAC_ALGORITHM);
this.hmac.init(secret);
this.properties = properties;
}

@PostMapping(path = "guides", consumes = "application/json", produces = "application/json")
public ResponseEntity<String> processGuidesUpdate(@RequestBody String payload,
@RequestHeader("X-Hub-Signature") String signature,
@RequestHeader(name = "X-GitHub-Event", required = false, defaultValue = "push") String event)
throws IOException {
verifyHmacSignature(payload, signature);
if (PING_EVENT.equals(event)) {
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }");
}
Map<?, ?> push = this.objectMapper.readValue(payload, Map.class);
logPayload(push);
RendererProperties.Webhook webhook = this.properties.getGithub().getWebhook();
this.service.triggerRespositoryDispatch(webhook.getActionOrg(), webhook.getActionRepo(),
webhook.getDispatchToken());
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }");
}

@ExceptionHandler(WebhookAuthenticationException.class)
public ResponseEntity<String> handleWebhookAuthenticationFailure(WebhookAuthenticationException exception) {
logger.error("Webhook authentication failure: " + exception.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"message\": \"Forbidden\" }");
}

@ExceptionHandler(IOException.class)
public ResponseEntity<String> handlePayloadParsingException(IOException exception) {
logger.error("Payload parsing exception", exception);
return ResponseEntity.badRequest().body("{ \"message\": \"Bad Request\" }");
}

private void verifyHmacSignature(String message, String signature) {
byte[] sig = hmac.doFinal(message.getBytes(CHARSET));
String computedSignature = "sha1=" + DatatypeConverter.printHexBinary(sig);
if (!computedSignature.equalsIgnoreCase(signature)) {
throw new WebhookAuthenticationException(computedSignature, signature);
}
}

private void logPayload(Map<?, ?> push) {
if (push.containsKey("head_commit")) {
final Object headCommit = push.get("head_commit");
if (headCommit != null) {
final Map<?, ?> headCommitMap = (Map<?, ?>) headCommit;
logger.info("Received new webhook payload for push with head_commit message: "
+ headCommitMap.get("message"));
}
}
else {
logger.info("Received new webhook payload for push, but with no head_commit");
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2022-2023 the original author or 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.
*/

package io.spring.renderer.guides.webhook;

/**
* Exception raised when the request to trigger a repository dispatch event returns
* anything other than an HTTP 2xx status.
*/
class RepositoryDispatchFailedException extends RuntimeException {

RepositoryDispatchFailedException(int statusCode) {
super(String.format("Repository Dispatch failed with status code: '%d'", statusCode));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2022-2023 the original author or 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.
*/

package io.spring.renderer.guides.webhook;

/**
* Exception raised when a github webhook message is received but its HMAC signature does
* not match the one computed with the shared secret.
*/
class WebhookAuthenticationException extends RuntimeException {

WebhookAuthenticationException(String expected, String actual) {
super(String.format("Could not verify signature: '%s', expected '%s'", actual, expected));
}

}
Loading

0 comments on commit 0c3c141

Please sign in to comment.