Skip to content

Commit

Permalink
feat: perform more chat token checks (#607)
Browse files Browse the repository at this point in the history
* feat: perform more token checks on chat build

* feat: add token validation on chat reconnect

* refactor: only validate after failure

* feat: add/use TwitchIdentityProvider#isCredentialValid

* refactor: rename validateReconnectToken setting

* fix: distinguish 5xx errors from token expiry

* refactor(chat): update credential manager usage

* refactor: use getIdentityProviderByName more

* refactor: use updateCredential more

* fix: reuse httpClient from OAuth2IdentityProvider

* fix: set default socket timeout to 30s

* chore: raise level of missing write scope log

* chore: raise log level of chat token checks

* chore: bump credential manager docs link

Co-authored-by: Philipp Heuer <git@philippheuer.me>
  • Loading branch information
iProdigy and PhilippHeuer committed Jul 30, 2022
1 parent aba86a4 commit 42e9026
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 28 deletions.
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()) {
// 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)
.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

0 comments on commit 42e9026

Please sign in to comment.