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

feat: perform more chat token checks #607

Merged
merged 15 commits into from Jul 30, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -38,6 +38,41 @@ public TwitchIdentityProvider(String clientId, String clientSecret, String redir
this.scopeSeperator = "+"; // Prevents a URISyntaxException when creating a URI from the authUrl
}

/**
* Checks whether an {@link OAuth2Credential} is valid.
* <p>
* Expired tokens will yield false (assuming the network request succeeds).
*
* @param credential the OAuth credential to check
* @return whether the token is valid, or empty if the network request did not succeed
*/
public Optional<Boolean> isCredentialValid(OAuth2Credential credential) {
if (credential == null || credential.getAccessToken() == null || credential.getAccessToken().isEmpty())
return Optional.of(false);

try {
// build request
Request request = new Request.Builder()
.url("https://id.twitch.tv/oauth2/validate")
.header("Authorization", "OAuth " + credential.getAccessToken())
.build();

// perform call
Response response = HTTP_CLIENT.newCall(request).execute();
PhilippHeuer marked this conversation as resolved.
Show resolved Hide resolved

// return token status
if (response.isSuccessful())
return Optional.of(true);

if (response.code() >= 400)
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
return Optional.of(false);
} catch (Exception ignored) {
// fall through to return empty
}

return Optional.empty();
}

/**
* Get Auth Token Information
*
Expand Down
90 changes: 77 additions & 13 deletions chat/src/main/java/com/github/twitch4j/chat/TwitchChat.java
Expand Up @@ -7,6 +7,7 @@
import com.github.philippheuer.credentialmanager.CredentialManager;
import com.github.philippheuer.credentialmanager.domain.OAuth2Credential;
import com.github.philippheuer.events4j.core.EventManager;
import com.github.twitch4j.auth.domain.TwitchScopes;
import com.github.twitch4j.auth.providers.TwitchIdentityProvider;
import com.github.twitch4j.chat.enums.CommandSource;
import com.github.twitch4j.chat.enums.NoticeTag;
Expand All @@ -32,6 +33,7 @@
import io.github.bucket4j.Bucket;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;

import java.time.Duration;
Expand All @@ -54,6 +56,7 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;

@Slf4j
Expand Down Expand Up @@ -230,6 +233,16 @@ public class TwitchChat implements ITwitchChat {
*/
protected final Cache<String, Bucket> bucketByChannelName;

/**
* Twitch Identity Provider
*/
protected final TwitchIdentityProvider identityProvider;

/**
* Whether OAuth token status should be checked on reconnect
*/
protected final boolean validateOnConnect;

/**
* Constructor
*
Expand Down Expand Up @@ -257,8 +270,9 @@ public class TwitchChat implements ITwitchChat {
* @param wsPingPeriod WebSocket Ping Period
* @param connectionBackoffStrategy WebSocket Connection Backoff Strategy
* @param perChannelRateLimit Per channel message limit
* @param validateOnConnect Whether token should be validated on connect
*/
public TwitchChat(WebsocketConnection websocketConnection, EventManager eventManager, CredentialManager credentialManager, OAuth2Credential chatCredential, String baseUrl, boolean sendCredentialToThirdPartyHost, Collection<String> commandPrefixes, Integer chatQueueSize, Bucket ircMessageBucket, Bucket ircWhisperBucket, Bucket ircJoinBucket, Bucket ircAuthBucket, ScheduledThreadPoolExecutor taskExecutor, long chatQueueTimeout, ProxyConfig proxyConfig, boolean autoJoinOwnChannel, boolean enableMembershipEvents, Collection<String> botOwnerIds, boolean removeChannelOnJoinFailure, int maxJoinRetries, long chatJoinTimeout, int wsPingPeriod, IBackoffStrategy connectionBackoffStrategy, Bandwidth perChannelRateLimit) {
public TwitchChat(WebsocketConnection websocketConnection, EventManager eventManager, CredentialManager credentialManager, OAuth2Credential chatCredential, String baseUrl, boolean sendCredentialToThirdPartyHost, Collection<String> commandPrefixes, Integer chatQueueSize, Bucket ircMessageBucket, Bucket ircWhisperBucket, Bucket ircJoinBucket, Bucket ircAuthBucket, ScheduledThreadPoolExecutor taskExecutor, long chatQueueTimeout, ProxyConfig proxyConfig, boolean autoJoinOwnChannel, boolean enableMembershipEvents, Collection<String> botOwnerIds, boolean removeChannelOnJoinFailure, int maxJoinRetries, long chatJoinTimeout, int wsPingPeriod, IBackoffStrategy connectionBackoffStrategy, Bandwidth perChannelRateLimit, boolean validateOnConnect) {
this.eventManager = eventManager;
this.credentialManager = credentialManager;
this.chatCredential = chatCredential;
Expand All @@ -278,6 +292,7 @@ public TwitchChat(WebsocketConnection websocketConnection, EventManager eventMan
this.maxJoinRetries = maxJoinRetries;
this.chatJoinTimeout = chatJoinTimeout;
this.perChannelRateLimit = perChannelRateLimit;
this.validateOnConnect = validateOnConnect;

// init per channel message buckets by channel name
this.bucketByChannelName = Caffeine.newBuilder()
Expand All @@ -302,18 +317,49 @@ public TwitchChat(WebsocketConnection websocketConnection, EventManager eventMan
this.connection = websocketConnection;
}

this.identityProvider = credentialManager.getOAuth2IdentityProviderByName("twitch")
.filter(ip -> ip instanceof TwitchIdentityProvider)
.map(ip -> (TwitchIdentityProvider) ip)
.orElse(new TwitchIdentityProvider(null, null, null));

// credential validation
if (this.chatCredential == null) {
log.info("TwitchChat: No ChatAccount provided, Chat will be joined anonymously! Please look at the docs Twitch4J -> Chat if this is unintentional");
} else if (this.chatCredential.getUserName() == null) {
log.debug("TwitchChat: AccessToken does not contain any user information, fetching using the CredentialManager ...");
} else {
Optional<OAuth2Credential> credential = identityProvider.getAdditionalCredentialInformation(this.chatCredential);

// credential manager
Optional<OAuth2Credential> credential = credentialManager.getOAuth2IdentityProviderByName("twitch")
.orElse(new TwitchIdentityProvider(null, null, null))
.getAdditionalCredentialInformation(this.chatCredential);
if (credential.isPresent()) {
this.chatCredential = credential.get();
OAuth2Credential enriched = credential.get();

// Update ChatCredential
if (this.chatCredential.getUserName() == null) {
log.debug("TwitchChat: AccessToken does not contain any user information, fetching using the CredentialManager ...");
this.chatCredential = enriched;
} else {
chatCredential.setExpiresIn(enriched.getExpiresIn());
if ((chatCredential.getScopes() == null || chatCredential.getScopes().isEmpty()) && enriched.getScopes() != null) {
chatCredential.getScopes().addAll(enriched.getScopes());
}
if ((chatCredential.getContext() == null || chatCredential.getContext().isEmpty()) && enriched.getContext() != null) {
chatCredential.getContext().putAll(enriched.getContext());
}
}

// Check token type
if (StringUtils.isEmpty(enriched.getUserId())) {
log.warn("TwitchChat: ChatAccount is an App Access Token, while IRC requires User Access! Chat will be joined anonymously to avoid errors.");
this.chatCredential = null; // connect anonymously to at least be able to read messages
}

// Check scopes
Collection<String> scopes = enriched.getScopes();
if (scopes == null || scopes.isEmpty() || (!scopes.contains(TwitchScopes.CHAT_READ.toString())) && !scopes.contains(TwitchScopes.KRAKEN_CHAT_LOGIN.toString())) {
log.warn("TwitchChat: AccessToken does not have required scope ({}) to connect to chat, joining anonymously instead!", TwitchScopes.CHAT_READ);
this.chatCredential = null; // connect anonymously to at least be able to read messages
}
if (scopes == null || !scopes.contains(TwitchScopes.CHAT_EDIT.toString())) {
log.debug("TwitchChat: AccessToken does not have the scope to write messages ({}).", TwitchScopes.CHAT_EDIT);
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
log.error("TwitchChat: Failed to get AccessToken Information, the token is probably not valid. Please check the docs Twitch4J -> Chat on how to obtain a valid token.");
}
Expand Down Expand Up @@ -451,20 +497,38 @@ protected void onConnected() {
boolean sendRealPass = sendCredentialToThirdPartyHost // check whether this security feature has been overridden
|| baseUrl.equalsIgnoreCase(TWITCH_WEB_SOCKET_SERVER) // check whether the url is exactly the official one
|| baseUrl.equalsIgnoreCase(TWITCH_WEB_SOCKET_SERVER.substring(0, TWITCH_WEB_SOCKET_SERVER.length() - 4)); // check whether the url matches without the port
sendTextToWebSocket(String.format("pass oauth:%s", sendRealPass ? chatCredential.getAccessToken() : CryptoUtils.generateNonce(30)), true);
userName = String.valueOf(chatCredential.getUserName()).toLowerCase();

String token;
if (sendRealPass) {
BooleanSupplier hasExpired = () -> identityProvider.isCredentialValid(chatCredential).filter(valid -> !valid).isPresent();
if (validateOnConnect && connection.getConfig().backoffStrategy().getFailures() > 1 && hasExpired.getAsBoolean()) {
log.warn("TwitchChat: Credential is no longer valid! Connecting anonymously...");
token = null;
} else {
token = chatCredential.getAccessToken();
}
} else {
token = CryptoUtils.generateNonce(30);
}

if (token != null) {
sendTextToWebSocket(String.format("pass oauth:%s", token), true);
userName = String.valueOf(chatCredential.getUserName()).toLowerCase();
} else {
userName = null;
}
} else {
userName = "justinfan" + ThreadLocalRandom.current().nextInt(100000);
userName = null;
}
sendTextToWebSocket(String.format("nick %s", userName), true);
sendTextToWebSocket(String.format("nick %s", userName != null ? userName : "justinfan" + ThreadLocalRandom.current().nextInt(100000)), true);

// Join defined channels, in case we reconnect or weren't connected yet when we called joinChannel
for (String channel : currentChannels) {
issueJoin(channel);
}

// then join to own channel - required for sending or receiving whispers
if (chatCredential != null && chatCredential.getUserName() != null) {
if (chatCredential != null && chatCredential.getUserName() != null && userName != null) {
if (autoJoinOwnChannel && !currentChannels.contains(userName))
joinChannel(userName);
} else {
Expand Down
Expand Up @@ -257,6 +257,17 @@ public class TwitchChatBuilder {
@With
private IBackoffStrategy connectionBackoffStrategy = null;

/**
* Whether the {@link #getChatAccount()} should be validated on reconnection failures.
* <p>
* If enabled and the token has expired, chat will connect in read-only mode instead.
* <p>
* If the network connection is too slow, you may want to disable this setting to avoid
* disconnects while waiting for the result of the validate endpoint.
*/
@With
private boolean validateReconnectToken = true;
iProdigy marked this conversation as resolved.
Show resolved Hide resolved

/**
* Initialize the builder
*
Expand Down Expand Up @@ -314,7 +325,7 @@ public TwitchChat build() {
perChannelRateLimit = chatRateLimit;

log.debug("TwitchChat: Initializing Module ...");
return new TwitchChat(this.websocketConnection, this.eventManager, this.credentialManager, this.chatAccount, this.baseUrl, this.sendCredentialToThirdPartyHost, this.commandPrefixes, this.chatQueueSize, this.ircMessageBucket, this.ircWhisperBucket, this.ircJoinBucket, this.ircAuthBucket, this.scheduledThreadPoolExecutor, this.chatQueueTimeout, this.proxyConfig, this.autoJoinOwnChannel, this.enableMembershipEvents, this.botOwnerIds, this.removeChannelOnJoinFailure, this.maxJoinRetries, this.chatJoinTimeout, this.wsPingPeriod, this.connectionBackoffStrategy, this.perChannelRateLimit);
return new TwitchChat(this.websocketConnection, this.eventManager, this.credentialManager, this.chatAccount, this.baseUrl, this.sendCredentialToThirdPartyHost, this.commandPrefixes, this.chatQueueSize, this.ircMessageBucket, this.ircWhisperBucket, this.ircJoinBucket, this.ircAuthBucket, this.scheduledThreadPoolExecutor, this.chatQueueTimeout, this.proxyConfig, this.autoJoinOwnChannel, this.enableMembershipEvents, this.botOwnerIds, this.removeChannelOnJoinFailure, this.maxJoinRetries, this.chatJoinTimeout, this.wsPingPeriod, this.connectionBackoffStrategy, this.perChannelRateLimit, this.validateReconnectToken);
}

/**
Expand Down
@@ -1,5 +1,6 @@
package com.github.twitch4j.kotlin.mock

import com.github.philippheuer.credentialmanager.CredentialManagerBuilder
import com.github.philippheuer.events4j.core.EventManager
import com.github.twitch4j.chat.TwitchChat
import com.github.twitch4j.chat.util.TwitchChatLimitHelper
Expand All @@ -11,7 +12,7 @@ import com.github.twitch4j.common.util.ThreadUtils
class MockChat : TwitchChat(
null,
EventManager().apply { autoDiscovery() },
null,
CredentialManagerBuilder.builder().build(),
null,
FDGT_TEST_SOCKET_SERVER,
false,
Expand All @@ -32,7 +33,8 @@ class MockChat : TwitchChat(
1,
0,
null,
TwitchChatLimitHelper.MOD_MESSAGE_LIMIT
TwitchChatLimitHelper.MOD_MESSAGE_LIMIT,
false
) {
@Volatile
var isConnected = false
Expand Down