Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add integrity token to GQL requests #286

Merged
merged 3 commits into from
Sep 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/analyse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
schedule:
- cron: '0 13 * * 1'

permissions:
security-events: write
actions: read
contents: read

jobs:
code-ql:
name: CodeQL
Expand All @@ -25,16 +30,16 @@ jobs:
distribution: 'temurin'
java-version: 17
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: java
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
env:
GITHUB_USER: RakSrinaNa
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

qodana:
name: Qodana
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private static void preSetup(){
Unirest.config()
.enableCookieManagement(true)
.setObjectMapper(new JacksonObjectMapper(JacksonUtils.getMapper()))
.setDefaultHeader(USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0")
.setDefaultHeader(USER_AGENT, "Mozilla/5.0 (X11; Linux x86_64; rv:104.0) Gecko/20100101 Firefox/104.0")
.interceptor(new UnirestLogger());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import fr.raksrinana.channelpointsminer.miner.api.gql.data.GQLError;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.GQLResponse;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.IGQLOperation;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.IntegrityResponse;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.channelfollows.ChannelFollowsData;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.channelfollows.ChannelFollowsOperation;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.channelpointscontext.ChannelPointsContextData;
Expand Down Expand Up @@ -32,37 +33,54 @@
import fr.raksrinana.channelpointsminer.miner.api.gql.data.videoplayerstreaminfooverlaychannel.VideoPlayerStreamInfoOverlayChannelData;
import fr.raksrinana.channelpointsminer.miner.api.gql.data.videoplayerstreaminfooverlaychannel.VideoPlayerStreamInfoOverlayChannelOperation;
import fr.raksrinana.channelpointsminer.miner.api.passport.TwitchLogin;
import fr.raksrinana.channelpointsminer.miner.api.passport.exceptions.IntegrityError;
import fr.raksrinana.channelpointsminer.miner.api.passport.exceptions.InvalidCredentials;
import fr.raksrinana.channelpointsminer.miner.factory.TimeFactory;
import kong.unirest.core.Unirest;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.RandomStringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import static kong.unirest.core.HeaderNames.AUTHORIZATION;

@Log4j2
@RequiredArgsConstructor
public class GQLApi{
private static final String ENDPOINT = "https://gql.twitch.tv/gql";
public static final String CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
private static final String ENDPOINT = "https://gql.twitch.tv";
private static final String CLIENT_INTEGRITY_HEADER = "Client-Integrity";
private static final String CLIENT_ID_HEADER = "Client-ID";
private static final String ORDER_DESC = "DESC";
private static final Set<String> EXPECTED_ERROR_MESSAGES = Set.of("service timeout", "service error", "server error", "service unavailable");
private static final String DEVICE_ID_HEADER = "Device-ID";
private static final int DEVICE_ID_LENGTH = 26;

private final TwitchLogin twitchLogin;
private final String deviceId;
private IntegrityResponse integrityResponse;

public GQLApi(TwitchLogin twitchLogin){
this.twitchLogin = twitchLogin;
this.deviceId = RandomStringUtils.randomAlphanumeric(DEVICE_ID_LENGTH);
}

@NotNull
public Optional<GQLResponse<ReportMenuItemData>> reportMenuItem(@NotNull String username){
return postRequest(new ReportMenuItemOperation(username));
return postGqlRequest(new ReportMenuItemOperation(username));
}

@NotNull
private <T> Optional<GQLResponse<T>> postRequest(@NotNull IGQLOperation<T> operation){
var response = Unirest.post(ENDPOINT)
private <T> Optional<GQLResponse<T>> postGqlRequest(@NotNull IGQLOperation<T> operation){
var response = Unirest.post(ENDPOINT + "/gql")
.header(AUTHORIZATION, "OAuth " + twitchLogin.getAccessToken())
.header(CLIENT_INTEGRITY_HEADER, getClientIntegrity())
.header(CLIENT_ID_HEADER, CLIENT_ID)
.header(DEVICE_ID_HEADER, deviceId)
.body(operation)
.asObject(operation.getResponseType());

Expand All @@ -89,6 +107,31 @@ private <T> Optional<GQLResponse<T>> postRequest(@NotNull IGQLOperation<T> opera
return Optional.ofNullable(response.getBody());
}

@NotNull
private String getClientIntegrity(){
if(Objects.nonNull(integrityResponse) && integrityResponse.getExpiration().isAfter(TimeFactory.now())){
return integrityResponse.getToken();
}

log.info("Querying new integrity token");
var response = Unirest.post(ENDPOINT + "/integrity")
.header(AUTHORIZATION, "OAuth " + twitchLogin.getAccessToken())
.asObject(IntegrityResponse.class);

if(!response.isSuccess()){
throw new RuntimeException(new IntegrityError(response.getStatus(), "Http code is not a success"));
}

var body = response.getBody();
if(Objects.isNull(body.getToken())){
throw new RuntimeException(new IntegrityError(response.getStatus(), body.getMessage()));
}

log.info("New integrity token will expire at {}", body.getExpiration());
integrityResponse = body;
return body.getToken();
}

private boolean isErrorExpected(@NotNull Collection<GQLError> errors){
return errors.stream().allMatch(this::isErrorExpected);
}
Expand All @@ -99,47 +142,47 @@ private boolean isErrorExpected(@NotNull GQLError error){

@NotNull
public Optional<GQLResponse<ChannelPointsContextData>> channelPointsContext(@NotNull String username){
return postRequest(new ChannelPointsContextOperation(username));
return postGqlRequest(new ChannelPointsContextOperation(username));
}

@NotNull
public Optional<GQLResponse<VideoPlayerStreamInfoOverlayChannelData>> videoPlayerStreamInfoOverlayChannel(@NotNull String username){
return postRequest(new VideoPlayerStreamInfoOverlayChannelOperation(username));
return postGqlRequest(new VideoPlayerStreamInfoOverlayChannelOperation(username));
}

@NotNull
public Optional<GQLResponse<DropsHighlightServiceAvailableDropsData>> dropsHighlightServiceAvailableDrops(@NotNull String channelId){
return postRequest(new DropsHighlightServiceAvailableDropsOperation(channelId));
return postGqlRequest(new DropsHighlightServiceAvailableDropsOperation(channelId));
}

@NotNull
public Optional<GQLResponse<ClaimCommunityPointsData>> claimCommunityPoints(@NotNull String channelId, @NotNull String claimId){
return postRequest(new ClaimCommunityPointsOperation(channelId, claimId));
return postGqlRequest(new ClaimCommunityPointsOperation(channelId, claimId));
}

@NotNull
public Optional<GQLResponse<CommunityMomentCalloutClaimData>> claimCommunityMoment(@NotNull String momentId){
return postRequest(new CommunityMomentCalloutClaimOperation(momentId));
return postGqlRequest(new CommunityMomentCalloutClaimOperation(momentId));
}

@NotNull
public Optional<GQLResponse<JoinRaidData>> joinRaid(@NotNull String raidId){
return postRequest(new JoinRaidOperation(raidId));
return postGqlRequest(new JoinRaidOperation(raidId));
}

@NotNull
public Optional<GQLResponse<InventoryData>> inventory(){
return postRequest(new InventoryOperation());
return postGqlRequest(new InventoryOperation());
}

@NotNull
public Optional<GQLResponse<DropsPageClaimDropRewardsData>> dropsPageClaimDropRewards(@NotNull String dropInstanceId){
return postRequest(new DropsPageClaimDropRewardsOperation(dropInstanceId));
return postGqlRequest(new DropsPageClaimDropRewardsOperation(dropInstanceId));
}

@NotNull
public Optional<GQLResponse<MakePredictionData>> makePrediction(@NotNull String eventId, @NotNull String outcomeId, int amount, @NotNull String transactionId){
return postRequest(new MakePredictionOperation(eventId, outcomeId, amount, transactionId));
return postGqlRequest(new MakePredictionOperation(eventId, outcomeId, amount, transactionId));
}

@NotNull
Expand Down Expand Up @@ -174,11 +217,11 @@ public List<User> allChannelFollows(){

@NotNull
public Optional<GQLResponse<ChannelFollowsData>> channelFollows(int limit, @NotNull String order, @Nullable String cursor){
return postRequest(new ChannelFollowsOperation(limit, order, cursor));
return postGqlRequest(new ChannelFollowsOperation(limit, order, cursor));
}

@NotNull
public Optional<GQLResponse<ChatRoomBanStatusData>> chatRoomBanStatus(@NotNull String channelId, @NotNull String targetUserId){
return postRequest(new ChatRoomBanStatusOperation(channelId, targetUserId));
return postGqlRequest(new ChatRoomBanStatusOperation(channelId, targetUserId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package fr.raksrinana.channelpointsminer.miner.api.gql.data;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import fr.raksrinana.channelpointsminer.miner.util.json.MillisecondsTimestampDeserializer;
import fr.raksrinana.channelpointsminer.miner.util.json.UnknownDeserializer;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.Instant;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@EqualsAndHashCode
public class IntegrityResponse{
@JsonProperty("expiration")
@JsonDeserialize(using = MillisecondsTimestampDeserializer.class)
private Instant expiration;
@JsonProperty("request_id")
private String requestId;
@JsonProperty("token")
private String token;
@JsonProperty("error")
@JsonDeserialize(using = UnknownDeserializer.class)
private Object error;
@JsonProperty("message")
private String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package fr.raksrinana.channelpointsminer.miner.api.passport.exceptions;

import org.jetbrains.annotations.Nullable;

/**
* Error while getting integrity token.
*/
public class IntegrityError extends Exception{
public IntegrityError(int statusCode, @Nullable String description){
super("Failed to get integrity token. Status code is " + statusCode + " : " + description);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package fr.raksrinana.channelpointsminer.miner.util.json;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import java.time.Instant;

public class MillisecondsTimestampDeserializer extends StdDeserializer<Instant>{
protected MillisecondsTimestampDeserializer(){
this(null);
}

protected MillisecondsTimestampDeserializer(Class<?> vc){
super(vc);
}

@Override
public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException{
var value = p.getValueAsLong(-1);
if(value < 0){
return null;
}
return Instant.ofEpochMilli(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import fr.raksrinana.channelpointsminer.miner.api.discord.data.Webhook;
import fr.raksrinana.channelpointsminer.miner.tests.TestUtils;
import fr.raksrinana.channelpointsminer.miner.tests.UnirestMock;
import fr.raksrinana.channelpointsminer.miner.tests.UnirestMockExtension;
import kong.unirest.core.HttpMethod;
import kong.unirest.core.MockClient;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -30,7 +30,7 @@ void setUp() throws MalformedURLException{
}

@Test
void nominal(MockClient unirest){
void nominal(UnirestMock unirest){
var webhook = Webhook.builder()
.username("UsernameWillBeOverriden")
.content("Test message")
Expand All @@ -48,7 +48,7 @@ void nominal(MockClient unirest){
}

@Test
void nominalRetryAfter(MockClient unirest){
void nominalRetryAfter(UnirestMock unirest){
var webhook = Webhook.builder()
.content("Test message")
.build();
Expand All @@ -65,7 +65,7 @@ void nominalRetryAfter(MockClient unirest){
}

@Test
void error(MockClient unirest){
void error(UnirestMock unirest){
var webhook = Webhook.builder()
.content("Test message")
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package fr.raksrinana.channelpointsminer.miner.api.gql;

import fr.raksrinana.channelpointsminer.miner.tests.TestUtils;
import fr.raksrinana.channelpointsminer.miner.tests.UnirestMock;
import static kong.unirest.core.HttpMethod.POST;

public abstract class AbstractGQLTest{
protected static final String ACCESS_TOKEN = "access-token";
private static final String INTEGRITY_TOKEN = "integrity-token";

protected void expectValidRequestOkWithIntegrityOk(UnirestMock unirest, String responseBody){
expectBodyRequestOkWithIntegrityOk(unirest, getValidRequest(), responseBody);
}

protected void expectBodyRequestOkWithIntegrityOk(UnirestMock unirest, String requestBody, String responseBody){
setupIntegrityOk(unirest);
expectBodyRequestOk(unirest, requestBody, responseBody);
}

protected abstract String getValidRequest();

protected void setupIntegrityOk(UnirestMock unirest){
unirest.expect(POST, "https://gql.twitch.tv/integrity")
.header("Authorization", "OAuth " + ACCESS_TOKEN)
.thenReturn(TestUtils.getAllResourceContent("api/gql/integrity_ok.json"))
.withStatus(200);
}

protected void expectBodyRequestOk(UnirestMock unirest, String requestBody, String responseBody){
expectRequest(unirest, requestBody, 200, responseBody);
}

private void expectRequest(UnirestMock unirest, String requestBody, int responseStatus, String responseBody){
unirest.expect(POST, "https://gql.twitch.tv/gql")
.header("Authorization", "OAuth " + ACCESS_TOKEN)
.header("Client-Integrity", INTEGRITY_TOKEN)
.header("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko")
.body(requestBody)
.thenReturn(responseBody == null ? null : TestUtils.getAllResourceContent(responseBody))
.withStatus(responseStatus);
}

protected void expectValidRequestOk(UnirestMock unirest, String responseBody){
expectBodyRequestOk(unirest, getValidRequest(), responseBody);
}

protected void expectValidRequestWithIntegrityOk(UnirestMock unirest, int responseStatus, String responseBody){
setupIntegrityOk(unirest);
expectRequest(unirest, getValidRequest(), responseStatus, responseBody);
}

protected void setupIntegrityWillNeedRefresh(UnirestMock unirest){
unirest.expect(POST, "https://gql.twitch.tv/integrity")
.header("Authorization", "OAuth " + ACCESS_TOKEN)
.thenReturn(TestUtils.getAllResourceContent("api/gql/integrity_needRefresh.json"))
.withStatus(200);
}

protected void setupIntegrityNoToken(UnirestMock unirest){
unirest.expect(POST, "https://gql.twitch.tv/integrity")
.header("Authorization", "OAuth " + ACCESS_TOKEN)
.thenReturn(TestUtils.getAllResourceContent("api/gql/integrity_noToken.json"))
.withStatus(200);
}

protected void setupIntegrityStatus(UnirestMock unirest, int status){
unirest.expect(POST, "https://gql.twitch.tv/integrity")
.header("Authorization", "OAuth " + ACCESS_TOKEN)
.thenReturn()
.withStatus(status);
}
}
Loading