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

Discord/GitHub account linking #665

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
20 changes: 20 additions & 0 deletions Refresh.Common/Extensions/HttpContentExtensions.cs
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);
}
}
35 changes: 35 additions & 0 deletions Refresh.Common/Extensions/NameValueCollectionExtensions.cs
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();
}
}
16 changes: 16 additions & 0 deletions Refresh.Common/Helpers/CryptoHelper.cs
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);
}
}
46 changes: 45 additions & 1 deletion Refresh.GameServer/Configuration/IntegrationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Refresh.GameServer.Configuration;
/// </summary>
public class IntegrationConfig : Config
{
public override int CurrentConfigVersion => 6;
public override int CurrentConfigVersion => 9;
Copy link
Member

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

Suggested change
public override int CurrentConfigVersion => 9;
public override int CurrentConfigVersion => 7;

public override int Version { get; set; }
protected override void Migrate(int oldVer, dynamic oldConfig)
{
Expand Down Expand Up @@ -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";
Copy link
Member

Choose a reason for hiding this comment

The 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";
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Down
98 changes: 98 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.OAuth.cs
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)
Copy link
Member

Choose a reason for hiding this comment

The 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);
Copy link
Member

Choose a reason for hiding this comment

The 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);
Copy link
Member

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetOAuthTokenByUser?

=> 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)
Copy link
Member

Choose a reason for hiding this comment

The 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 Remove as opposed to Revoke

{
this.Write(() =>
{
this.OAuthTokenRelations.Remove(token);
});
}
}
13 changes: 2 additions & 11 deletions Refresh.GameServer/Database/GameDatabaseContext.Tokens.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using JetBrains.Annotations;
using Refresh.Common.Helpers;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Types.UserData;

Expand All @@ -23,16 +24,6 @@ static GameDatabaseContext()
GameCookieLength = (int)Math.Floor((MaxGameCookieLength - GameCookieHeader.Length - MaxBase64Padding) * 3 / 4.0);
}

private static string GetTokenString(int length)
{
byte[] tokenData = new byte[length];

using RandomNumberGenerator rng = RandomNumberGenerator.Create();
rng.GetBytes(tokenData);

return Convert.ToBase64String(tokenData);
}

public Token GenerateTokenForUser(GameUser user, TokenType type, TokenGame game, TokenPlatform platform, string ipAddress, int tokenExpirySeconds = DefaultTokenExpirySeconds)
{
// TODO: JWT (JSON Web Tokens) for TokenType.Api
Expand All @@ -42,7 +33,7 @@ public Token GenerateTokenForUser(GameUser user, TokenType type, TokenGame game,
Token token = new()
{
User = user,
TokenData = GetTokenString(cookieLength),
TokenData = CryptoHelper.GetRandomBase64String(cookieLength),
TokenType = type,
TokenGame = game,
TokenPlatform = platform,
Expand Down
3 changes: 3 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ public void UpdateUserData(GameUser user, ApiUpdateUserRequest data)

if (data.ShowModdedContent != null)
user.ShowModdedContent = data.ShowModdedContent.Value;

if (data.DiscordProfileVisibility != null)
Copy link
Member

Choose a reason for hiding this comment

The 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;
});
}

Expand Down
5 changes: 5 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@
using Refresh.GameServer.Types.Contests;
using Refresh.GameServer.Types.Levels;
using Refresh.GameServer.Types.Notifications;
using Refresh.GameServer.Types.OAuth;
using Refresh.GameServer.Types.OAuth.Discord;
using Refresh.GameServer.Types.Photos;
using Refresh.GameServer.Types.Playlists;
using Refresh.GameServer.Types.Relations;
using Refresh.GameServer.Types.Reviews;
using Refresh.GameServer.Types.UserData;
using Refresh.GameServer.Types.UserData.Leaderboard;
using OAuthRequest = Refresh.GameServer.Types.OAuth.OAuthRequest;

namespace Refresh.GameServer.Database;

Expand Down Expand Up @@ -61,6 +64,8 @@ public partial class GameDatabaseContext : RealmDatabaseContext
private RealmDbSet<GamePlaylist> GamePlaylists => new(this._realm);
private RealmDbSet<LevelPlaylistRelation> LevelPlaylistRelations => new(this._realm);
private RealmDbSet<SubPlaylistRelation> SubPlaylistRelations => new(this._realm);
private RealmDbSet<OAuthRequest> OAuthRequests => new(this._realm);
private RealmDbSet<OAuthTokenRelation> OAuthTokenRelations => new(this._realm);

internal GameDatabaseContext(IDateTimeProvider time)
{
Expand Down
9 changes: 8 additions & 1 deletion Refresh.GameServer/Database/GameDatabaseProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
using Refresh.GameServer.Types.Contests;
using Refresh.GameServer.Types.Levels.SkillRewards;
using Refresh.GameServer.Types.Notifications;
using Refresh.GameServer.Types.OAuth;
using Refresh.GameServer.Types.OAuth.Discord;
using Refresh.GameServer.Types.Relations;
using Refresh.GameServer.Types.Reviews;
using Refresh.GameServer.Types.UserData.Leaderboard;
using Refresh.GameServer.Types.Photos;
using Refresh.GameServer.Types.Playlists;
using OAuthRequest = Refresh.GameServer.Types.OAuth.OAuthRequest;

namespace Refresh.GameServer.Database;

Expand All @@ -34,7 +37,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
this._time = time;
}

protected override ulong SchemaVersion => 159;
protected override ulong SchemaVersion => 162;

protected override string Filename => "refreshGameServer.realm";

Expand Down Expand Up @@ -86,6 +89,10 @@ protected GameDatabaseProvider(IDateTimeProvider time)
typeof(GamePlaylist),
typeof(LevelPlaylistRelation),
typeof(SubPlaylistRelation),

// oauth
typeof(OAuthRequest),
typeof(OAuthTokenRelation),
];

public override void Warmup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ public class ApiNotFoundError : ApiError

public const string ContestMissingErrorWhen = "The contest could not be found";
public static readonly ApiNotFoundError ContestMissingError = new(ContestMissingErrorWhen);

public const string OAuthTokenMissingErrorWhen = "An OAuth token for this user could not be found";
public static readonly ApiNotFoundError OAuthTokenMissingError = new(OAuthTokenMissingErrorWhen);

public const string OAuthProviderMissingErrorWhen = "The OAuth provider could not be found";
public static readonly ApiNotFoundError OAuthProviderMissingError = new(OAuthProviderMissingErrorWhen);

private ApiNotFoundError() : base("The requested resource was not found", NotFound)
{}

Expand Down
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)
{}
}
5 changes: 1 addition & 4 deletions Refresh.GameServer/Endpoints/ApiV3/DataTypes/IApiResponse.cs
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
Expand Up @@ -20,4 +20,5 @@ public class ApiUpdateUserRequest

public Visibility? LevelVisibility { get; set; }
public Visibility? ProfileVisibility { get; set; }
public Visibility? DiscordProfileVisibility { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Copy link
Member

Choose a reason for hiding this comment

The 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; }
Expand Down
Loading
Loading