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 all 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 @@ -33,7 +33,7 @@ public TwitchAuth(CredentialManager credentialManager, String clientId, String c

public static void registerIdentityProvider(CredentialManager credentialManager, String clientId, String clientSecret, String redirectUrl) {
// register the twitch identityProvider
Optional<OAuth2IdentityProvider> ip = credentialManager.getOAuth2IdentityProviderByName("twitch");
Optional<TwitchIdentityProvider> ip = credentialManager.getIdentityProviderByName("twitch", TwitchIdentityProvider.class);
if (!ip.isPresent()) {
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
// register
IdentityProvider identityProvider = new TwitchIdentityProvider(clientId, clientSecret, redirectUrl);
Expand Down
Expand Up @@ -6,7 +6,6 @@
import com.github.philippheuer.credentialmanager.identityprovider.OAuth2IdentityProvider;
import lombok.extern.slf4j.Slf4j;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
Expand All @@ -21,8 +20,6 @@
@Slf4j
public class TwitchIdentityProvider extends OAuth2IdentityProvider {

private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();

/**
* Constructor
*
Expand All @@ -38,6 +35,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 = httpClient.newCall(request).execute();

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

if (response.code() >= 400 && response.code() < 500)
return Optional.of(false);
} catch (Exception ignored) {
// fall through to return empty
}

return Optional.empty();
}

/**
* Get Auth Token Information
*
Expand All @@ -51,7 +83,7 @@ public Optional<OAuth2Credential> getAdditionalCredentialInformation(OAuth2Crede
.header("Authorization", "OAuth " + credential.getAccessToken())
.build();

Response response = HTTP_CLIENT.newCall(request).execute();
Response response = httpClient.newCall(request).execute();
String responseBody = response.body().string();

// parse response
Expand Down Expand Up @@ -101,7 +133,7 @@ public boolean revokeCredential(OAuth2Credential credential) {
.build();

try {
Response response = HTTP_CLIENT.newCall(request).execute();
Response response = httpClient.newCall(request).execute();
if (response.isSuccessful()) {
return true;
} else {
Expand Down
4 changes: 2 additions & 2 deletions build.gradle.kts
Expand Up @@ -30,7 +30,7 @@ allprojects {
// "https://javadoc.io/doc/com.squareup.okhttp3/okhttp/4.9.3", // blocked by https://github.com/square/okhttp/issues/6450
"https://javadoc.io/doc/com.github.philippheuer.events4j/events4j-core/0.10.0",
"https://javadoc.io/doc/com.github.philippheuer.events4j/events4j-handler-simple/0.10.0",
"https://javadoc.io/doc/com.github.philippheuer.credentialmanager/credentialmanager/0.1.2",
"https://javadoc.io/doc/com.github.philippheuer.credentialmanager/credentialmanager/0.1.4",
"https://javadoc.io/doc/io.github.openfeign/feign-slf4j/11.9.1",
"https://javadoc.io/doc/io.github.openfeign/feign-okhttp/11.9.1",
"https://javadoc.io/doc/io.github.openfeign/feign-jackson/11.9.1",
Expand Down Expand Up @@ -96,7 +96,7 @@ subprojects {
api(group = "com.github.philippheuer.events4j", name = "events4j-handler-simple", version = "0.10.0")

// Credential Manager
api(group = "com.github.philippheuer.credentialmanager", name = "credentialmanager", version = "0.1.2")
api(group = "com.github.philippheuer.credentialmanager", name = "credentialmanager", version = "0.1.4")

// HTTP Client
api(group = "io.github.openfeign", name = "feign-slf4j", version = "11.9.1")
Expand Down
77 changes: 64 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,36 @@ public TwitchChat(WebsocketConnection websocketConnection, EventManager eventMan
this.connection = websocketConnection;
}

this.identityProvider = credentialManager.getIdentityProviderByName("twitch", TwitchIdentityProvider.class)
.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
chatCredential.updateCredential(enriched);

// Check token type
if (StringUtils.isEmpty(enriched.getUserId())) {
log.error("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.isEmpty() || (!scopes.contains(TwitchScopes.CHAT_READ.toString())) && !scopes.contains(TwitchScopes.KRAKEN_CHAT_LOGIN.toString())) {
log.error("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.contains(TwitchScopes.CHAT_EDIT.toString())) {
log.warn("TwitchChat: AccessToken does not have the scope to write messages ({}). Consider joining anonymously if this is intentional...", TwitchScopes.CHAT_EDIT);
}
} 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 +484,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
21 changes: 17 additions & 4 deletions chat/src/main/java/com/github/twitch4j/chat/TwitchChatBuilder.java
Expand Up @@ -6,6 +6,7 @@
import com.github.philippheuer.events4j.api.service.IEventHandler;
import com.github.philippheuer.events4j.core.EventManager;
import com.github.philippheuer.events4j.simple.SimpleEventHandler;
import com.github.twitch4j.auth.TwitchAuth;
import com.github.twitch4j.auth.providers.TwitchIdentityProvider;
import com.github.twitch4j.chat.events.channel.ChannelJoinFailureEvent;
import com.github.twitch4j.chat.util.TwitchChatLimitHelper;
Expand Down Expand Up @@ -257,6 +258,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 verifyChatAccountOnReconnect = true;

/**
* Initialize the builder
*
Expand Down Expand Up @@ -284,16 +296,17 @@ public TwitchChat build() {
if (credentialManager == null) {
credentialManager = CredentialManagerBuilder.builder().build();
}
TwitchAuth.registerIdentityProvider(credentialManager, clientId, clientSecret, null);

// Register rate limits across the user id contained within the chat token
final String userId;
if (chatAccount == null) {
userId = null;
} else {
if (StringUtils.isEmpty(chatAccount.getUserId())) {
chatAccount = credentialManager.getOAuth2IdentityProviderByName("twitch")
.orElse(new TwitchIdentityProvider(null, null, null))
.getAdditionalCredentialInformation(chatAccount).orElse(chatAccount);
credentialManager.getIdentityProviderByName("twitch", TwitchIdentityProvider.class)
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
.flatMap(tip -> tip.getAdditionalCredentialInformation(chatAccount))
.ifPresent(chatAccount::updateCredential);
}
userId = StringUtils.defaultIfEmpty(chatAccount.getUserId(), null);
}
Expand All @@ -314,7 +327,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.verifyChatAccountOnReconnect);
}

/**
Expand Down
Expand Up @@ -73,7 +73,7 @@ public void validate() {
/**
* Websocket timeout milliseconds for read and write operations (0 = disabled).
*/
private int socketTimeout = 10_000;
private int socketTimeout = 30_000;

/**
* WebSocket Headers
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