-
Notifications
You must be signed in to change notification settings - Fork 25
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
Discord/GitHub account linking #665
base: main
Are you sure you want to change the base?
Changes from all commits
ca8b006
4f01da4
d9f68d9
a4ffedc
1eb79db
f8e239e
717226b
3e08962
f76cecb
db67b04
f77673f
16dc276
f156469
372196e
3d51242
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
using System.Xml; | ||
using System.Xml.Serialization; | ||
using Newtonsoft.Json; | ||
|
||
namespace Refresh.Common.Extensions; | ||
|
||
public static class HttpContentExtensions | ||
{ | ||
public static T ReadAsXml<T>(this HttpContent content) | ||
{ | ||
XmlSerializer serializer = new(typeof(T)); | ||
|
||
return (T)serializer.Deserialize(new XmlTextReader(content.ReadAsStream()))!; | ||
} | ||
|
||
public static T? ReadAsJson<T>(this HttpContent content) | ||
{ | ||
return JsonConvert.DeserializeObject<T>(content.ReadAsStringAsync().Result); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
using System.Collections.Specialized; | ||
using System.Text; | ||
using System.Web; | ||
|
||
namespace Refresh.Common.Extensions; | ||
|
||
public static class NameValueCollectionExtensions | ||
{ | ||
public static string ToQueryString(this NameValueCollection queryParams) | ||
{ | ||
StringBuilder builder = new(); | ||
|
||
if (queryParams.Count == 0) | ||
return string.Empty; | ||
|
||
builder.Append('?'); | ||
for (int i = 0; i < queryParams.Count; i++) | ||
{ | ||
string? key = queryParams.GetKey(i); | ||
string? val = queryParams.Get(i); | ||
|
||
if (key == null) | ||
continue; | ||
|
||
builder.Append(HttpUtility.UrlEncode(key)); | ||
builder.Append('='); | ||
if(val != null) | ||
builder.Append(HttpUtility.UrlEncode(val)); | ||
|
||
builder.Append('&'); | ||
} | ||
|
||
return builder.ToString(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
using System.Security.Cryptography; | ||
|
||
namespace Refresh.Common.Helpers; | ||
|
||
public static class CryptoHelper | ||
{ | ||
public static string GetRandomBase64String(int length) | ||
{ | ||
byte[] tokenData = new byte[length]; | ||
|
||
using RandomNumberGenerator rng = RandomNumberGenerator.Create(); | ||
rng.GetBytes(tokenData); | ||
|
||
return Convert.ToBase64String(tokenData); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,7 +7,7 @@ namespace Refresh.GameServer.Configuration; | |
/// </summary> | ||
public class IntegrationConfig : Config | ||
{ | ||
public override int CurrentConfigVersion => 6; | ||
public override int CurrentConfigVersion => 9; | ||
public override int Version { get; set; } | ||
protected override void Migrate(int oldVer, dynamic oldConfig) | ||
{ | ||
|
@@ -38,6 +38,50 @@ protected override void Migrate(int oldVer, dynamic oldConfig) | |
public string DiscordNickname { get; set; } = "Refresh"; | ||
public string DiscordAvatarUrl { get; set; } = "https://raw.githubusercontent.com/LittleBigRefresh/Branding/main/icons/refresh_512x.png"; | ||
|
||
#endregion | ||
|
||
#region Discord OAuth | ||
|
||
/// <summary> | ||
/// Whether to enable discord OAuth support for account linking | ||
/// </summary> | ||
public bool DiscordOAuthEnabled { get; set; } | ||
|
||
/// <summary> | ||
/// The redirect URL to use for Discord OAuth requests, ex. `https://lbp.littlebigrefresh.com/api/v3/oauth/authenticate` | ||
/// </summary> | ||
public string DiscordOAuthRedirectUrl { get; set; } = "http://localhost:10061/api/v3/oauth/authenticate"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Couldn't this be removed in favor of concatenating the endpoint path to ExternalUrl? |
||
/// <summary> | ||
/// The client ID of the OAuth application | ||
/// </summary> | ||
public string DiscordOAuthClientId { get; set; } = ""; | ||
/// <summary> | ||
/// The client secret of the OAuth application | ||
/// </summary> | ||
public string DiscordOAuthClientSecret { get; set; } = ""; | ||
|
||
#endregion | ||
|
||
#region GitHub OAuth | ||
|
||
/// <summary> | ||
/// Whether to enable GitHub OAuth support for account linking | ||
/// </summary> | ||
public bool GitHubOAuthEnabled { get; set; } | ||
|
||
/// <summary> | ||
/// The redirect URL to use for GitHub OAuth requests, ex. `https://lbp.littlebigrefresh.com/api/v3/oauth/authenticate` | ||
/// </summary> | ||
public string GitHubOAuthRedirectUrl { get; set; } = "http://localhost:10061/api/v3/oauth/authenticate"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here |
||
/// <summary> | ||
/// The client ID of the OAuth application | ||
/// </summary> | ||
public string GitHubOAuthClientId { get; set; } = ""; | ||
/// <summary> | ||
/// The client secret of the OAuth application | ||
/// </summary> | ||
public string GitHubOAuthClientSecret { get; set; } = ""; | ||
|
||
#endregion | ||
|
||
#region AIPI | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
using Refresh.Common.Helpers; | ||
using Refresh.GameServer.Time; | ||
using Refresh.GameServer.Types.OAuth; | ||
using Refresh.GameServer.Types.OAuth.Discord; | ||
using Refresh.GameServer.Types.UserData; | ||
using OAuthRequest = Refresh.GameServer.Types.OAuth.OAuthRequest; | ||
|
||
namespace Refresh.GameServer.Database; | ||
|
||
public partial class GameDatabaseContext // oauth | ||
{ | ||
public string CreateOAuthRequest(GameUser user, IDateTimeProvider timeProvider, OAuthProvider provider) | ||
{ | ||
string state = CryptoHelper.GetRandomBase64String(128); | ||
|
||
this.Write(() => | ||
{ | ||
this.OAuthRequests.Add(new OAuthRequest | ||
{ | ||
User = user, | ||
State = state, | ||
ExpiresAt = timeProvider.Now + TimeSpan.FromHours(1), // 1 hour expiry | ||
Provider = provider, | ||
}); | ||
}); | ||
|
||
return state; | ||
} | ||
|
||
/// <summary> | ||
/// Returns the OAuthProvider used in a request | ||
/// </summary> | ||
/// <param name="state">The OAuth request state</param> | ||
/// <returns>The provider, or null if no request was found with that state</returns> | ||
public OAuthProvider? OAuthGetProviderForRequest(string state) | ||
=> this.OAuthRequests.FirstOrDefault(d => d.State == state)?.Provider; | ||
|
||
public GameUser SaveOAuthToken(string state, OAuth2AccessTokenResponse tokenResponse, IDateTimeProvider timeProvider, OAuthProvider provider) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create or Add sound better here for database function names |
||
{ | ||
OAuthRequest request = this.OAuthRequests.First(d => d.State == state); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No sanity check? |
||
GameUser user = request.User; | ||
|
||
this.Write(() => | ||
{ | ||
OAuthTokenRelation? relation = this.OAuthTokenRelations.FirstOrDefault(d => d.User == request.User && d._Provider == (int)provider); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should get moved out of the write block |
||
if (relation == null) | ||
{ | ||
this.OAuthTokenRelations.Add(relation = new OAuthTokenRelation | ||
{ | ||
User = request.User, | ||
Provider = request.Provider, | ||
}); | ||
} | ||
|
||
this.UpdateOAuthToken(relation, tokenResponse, timeProvider); | ||
|
||
this.OAuthRequests.Remove(request); | ||
}); | ||
|
||
return user; | ||
} | ||
|
||
public void UpdateOAuthToken(OAuthTokenRelation token, OAuth2AccessTokenResponse tokenResponse, IDateTimeProvider timeProvider) | ||
{ | ||
this.Write(() => | ||
{ | ||
token.AccessToken = tokenResponse.AccessToken; | ||
token.RefreshToken = tokenResponse.RefreshToken; | ||
// If we don't have a revocation date, then we assume it never expires, and will just handle a revoked token at request time | ||
token.AccessTokenRevocationTime = tokenResponse.ExpiresIn == null ? DateTimeOffset.MaxValue : timeProvider.Now + TimeSpan.FromSeconds(tokenResponse.ExpiresIn.Value); | ||
}); | ||
} | ||
|
||
public OAuthTokenRelation? GetOAuthTokenFromUser(GameUser user, OAuthProvider provider) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
=> this.OAuthTokenRelations.FirstOrDefault(d => d.User == user && d._Provider == (int)provider); | ||
|
||
public int RemoveAllExpiredOAuthRequests(IDateTimeProvider timeProvider) | ||
{ | ||
IQueryable<OAuthRequest> expired = this.OAuthRequests.Where(d => d.ExpiresAt < timeProvider.Now); | ||
|
||
int removed = expired.Count(); | ||
|
||
this.Write(() => | ||
{ | ||
this.OAuthRequests.RemoveRange(expired); | ||
}); | ||
|
||
return removed; | ||
} | ||
|
||
public void RevokeOAuthToken(OAuthTokenRelation token) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tokens aren't something we actually 'own' in the database, so we should probably use |
||
{ | ||
this.Write(() => | ||
{ | ||
this.OAuthTokenRelations.Remove(token); | ||
}); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -203,6 +203,9 @@ public void UpdateUserData(GameUser user, ApiUpdateUserRequest data) | |
|
||
if (data.ShowModdedContent != null) | ||
user.ShowModdedContent = data.ShowModdedContent.Value; | ||
|
||
if (data.DiscordProfileVisibility != null) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this meant to be a general OAuth thing or just Discord specifically? |
||
user.DiscordProfileVisibility = data.DiscordProfileVisibility.Value; | ||
}); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
namespace Refresh.GameServer.Endpoints.ApiV3.ApiTypes.Errors; | ||
|
||
public class ApiNotSupportedError : ApiError | ||
{ | ||
public static readonly ApiNotSupportedError Instance = new(); | ||
|
||
public const string OAuthProviderTokenRevocationUnsupportedErrorWhen = "This OAuth provider does not support token revocation"; | ||
public static readonly ApiNotSupportedError OAuthProviderTokenRevocationUnsupportedError = new(OAuthProviderTokenRevocationUnsupportedErrorWhen); | ||
|
||
public const string OAuthProviderDisabledErrorWhen = "The server does not have this OAuth provider enabled"; | ||
public static readonly ApiNotSupportedError OAuthProviderDisabledError = new(OAuthProviderDisabledErrorWhen); | ||
|
||
private ApiNotSupportedError() : base("The server is not configured to support this endpoint.", NotImplemented) | ||
{} | ||
|
||
private ApiNotSupportedError(string message) : base(message, NotImplemented) | ||
{} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,3 @@ | ||
namespace Refresh.GameServer.Endpoints.ApiV3.DataTypes; | ||
|
||
public interface IApiResponse | ||
{ | ||
|
||
} | ||
public interface IApiResponse; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,6 +44,7 @@ public class ApiInstanceResponse : IApiResponse | |
|
||
public required IEnumerable<ApiGameAnnouncementResponse> Announcements { get; set; } | ||
public required ApiRichPresenceConfigurationResponse RichPresenceConfiguration { get; set; } | ||
public required bool DiscordOAuthEnabled { get; set; } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here; missing GitHub flag or just a general OAuth thing? |
||
|
||
public required bool MaintenanceModeEnabled { get; set; } | ||
public required string? GrafanaDashboardUrl { get; set; } | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No reason to jump this far for a config file