Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions Refresh.Core/Services/GameAuthenticationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Reflection;
using Bunkum.Core;
using Bunkum.Core.Authentication;
using Bunkum.Core.Database;
using Bunkum.Core.Endpoints;
using Bunkum.Core.Responses;
using Bunkum.Core.Services;
using Bunkum.Listener.Protocol;
using Bunkum.Listener.Request;
using NotEnoughLogs;
using Refresh.Database.Models.Authentication;

namespace Refresh.Core.Services;

// Referenced from https://github.com/PlanetBunkum/Bunkum/blob/main/Bunkum.Core/Services/AuthenticationService.cs
// purposefully a less optimized implementation (as in it doesn't cache the token)
public class GameAuthenticationService : Service
{
private readonly IAuthenticationProvider<Token> _provider;

public GameAuthenticationService(Logger logger, IAuthenticationProvider<Token> provider) : base(logger)
{
this._provider = provider;
}

public override Response? OnRequestHandled(ListenerContext context, MethodInfo method, Lazy<IDatabaseContext> database)
{
if (!(method.GetCustomAttribute<AuthenticationAttribute>()?.Required ?? true)) return null;

if (this.AuthenticateToken(context, database) == null)
return new Response("Not authenticated", ContentType.Plaintext, Forbidden);

return null;
}

public override object? AddParameterToEndpoint(ListenerContext context, BunkumParameterInfo parameter, Lazy<IDatabaseContext> database)
{
if (ParameterBasedFrom<IToken<IUser>>(parameter))
{
return this.AuthenticateToken(context, database);
}

if (ParameterBasedFrom<IUser>(parameter))
{
IToken<IUser>? token = this.AuthenticateToken(context, database);
if (token != null) return token.User;
}

return null;
}

public Token? AuthenticateToken(ListenerContext context, Lazy<IDatabaseContext> database)
{
return this._provider.AuthenticateToken(context, database);
}
}
39 changes: 39 additions & 0 deletions Refresh.Core/Services/GameRateLimitService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Reflection;
using Bunkum.Core.Database;
using Bunkum.Core.RateLimit;
using Bunkum.Core.Responses;
using Bunkum.Core.Services;
using Bunkum.Listener.Protocol;
using Bunkum.Listener.Request;
using NotEnoughLogs;
using Refresh.Database.Models.Users;

namespace Refresh.Core.Services;

// Referenced from https://github.com/PlanetBunkum/Bunkum/blob/main/Bunkum.Core/Services/RateLimitService.cs
public class GameRateLimitService : Service
{
private readonly IRateLimiter _rateLimiter;
private readonly GameAuthenticationService _authService;

internal GameRateLimitService(Logger logger, GameAuthenticationService authService, IRateLimiter rateLimiter) : base(logger)
{
this._rateLimiter = rateLimiter;
this._authService = authService;
}

public override Response? OnRequestHandled(ListenerContext context, MethodInfo method, Lazy<IDatabaseContext> database)
{
GameUser? user = this._authService.AuthenticateToken(context, database)?.User;

bool violated = false;

if (user != null)
violated = this._rateLimiter.UserViolatesRateLimit(context, method, user);
else
violated = this._rateLimiter.RemoteEndpointViolatesRateLimit(context, method);

if (violated) return new Response("You have been rate-limited.", ContentType.Plaintext, TooManyRequests);
return null;
}
}
9 changes: 3 additions & 6 deletions Refresh.Core/Services/RoleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
namespace Refresh.Core.Services;

/// <summary>
/// A service that hooks into the AuthenticationService, adding extra checks for roles.
/// A service that hooks into the GameAuthenticationService, adding extra checks for roles.
/// </summary>
public class RoleService : Service
{
private readonly AuthenticationService _authService;
private readonly GameAuthenticationService _authService;
private readonly GameServerConfig _config;

internal RoleService(AuthenticationService authService, GameServerConfig config, Logger logger) : base(logger)
internal RoleService(GameAuthenticationService authService, GameServerConfig config, Logger logger) : base(logger)
{
this._authService = authService;
this._config = config;
Expand All @@ -39,14 +39,12 @@ internal RoleService(AuthenticationService authService, GameServerConfig config,
// if the user's role is lower than the minimum role for this endpoint, then return unauthorized
if (user.Role < minimumRole)
{
this._authService.RemoveTokenFromCache();
return Unauthorized;
}

RequireEmailVerifiedAttribute? emailAttrib = method.GetCustomAttribute<RequireEmailVerifiedAttribute>();
if (emailAttrib != null && !user.EmailAddressVerified)
{
this._authService.RemoveTokenFromCache();
return Unauthorized;
}

Expand All @@ -64,7 +62,6 @@ internal RoleService(AuthenticationService authService, GameServerConfig config,
// If user isn't an admin, then stop the request here, ignoring all
if (user.Role != GameUserRole.Admin)
{
this._authService.RemoveTokenFromCache();
return Forbidden;
}

Expand Down
4 changes: 2 additions & 2 deletions Refresh.Core/Types/Data/DataContextService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ public class DataContextService : Service
{
private readonly StorageService _storageService;
private readonly MatchService _matchService;
private readonly AuthenticationService _authService;
private readonly GameAuthenticationService _authService;
private readonly GuidCheckerService _guidCheckerService;
private readonly CacheService _cacheService;

public DataContextService(StorageService storage, MatchService match, AuthenticationService auth, Logger logger, GuidCheckerService guidChecker, CacheService cache) : base(logger)
public DataContextService(StorageService storage, MatchService match, GameAuthenticationService auth, Logger logger, GuidCheckerService guidChecker, CacheService cache) : base(logger)
{
this._storageService = storage;
this._matchService = match;
Expand Down
9 changes: 9 additions & 0 deletions Refresh.Database/GameDatabaseContext.Tokens.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Refresh.Database.Models.Authentication;
using Refresh.Database.Models.Users;
using Refresh.Database.Models.Relations;
using System.Diagnostics;

namespace Refresh.Database;

Expand Down Expand Up @@ -84,6 +85,14 @@ public Token GenerateTokenForUser(GameUser user, TokenType type, TokenGame game,
return null;
}

if (token.TokenData != tokenData || token.TokenType != type)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"GetTokenFromTokenData - Token data or type does not match!");
}

return token;
}

Expand Down
15 changes: 14 additions & 1 deletion Refresh.Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Refresh.Database.Models.Levels;
using Refresh.Database.Models.Photos;
using Refresh.Database.Models.Assets;
using System.Diagnostics;

namespace Refresh.Database;

Expand Down Expand Up @@ -64,8 +65,20 @@ public partial class GameDatabaseContext // Users
public GameUser? GetUserByEmailAddress(string? emailAddress)
{
if (emailAddress == null) return null;

emailAddress = emailAddress.ToLowerInvariant();
return this.GameUsersIncluded.FirstOrDefault(u => u.EmailAddress == emailAddress);
GameUser? user = this.GameUsersIncluded.FirstOrDefault(u => u.EmailAddress == emailAddress);
if (user == null) return null;

if (user.EmailAddress != emailAddress)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"GetUserByEmailAddress - Found user's email does not match given email!");
}

return user;
}

[Pure]
Expand Down
5 changes: 3 additions & 2 deletions Refresh.Database/Models/Authentication/Token.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ public partial class Token : IToken<GameUser>
[Required]
public string IpAddress { get; set; }

[Required]
public GameUser User { get; set; }
[ForeignKey(nameof(UserId))]
[Required] public GameUser User { get; set; }
[Required] public ObjectId UserId { get; set; }

/// <summary>
/// The digest key to use with this token, determined from the first game request created by this token
Expand Down
23 changes: 22 additions & 1 deletion Refresh.GameServer/Authentication/GameAuthenticationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@
using Refresh.Interfaces.APIv3;
using Refresh.Interfaces.Game;
using Refresh.Interfaces.Internal;
using NotEnoughLogs;
using Bunkum.Core;

namespace Refresh.GameServer.Authentication;

public class GameAuthenticationProvider : IAuthenticationProvider<Token>
{
private readonly GameServerConfig? _config;
private readonly Logger _logger;

public GameAuthenticationProvider(GameServerConfig? config)
public GameAuthenticationProvider(GameServerConfig? config, Logger logger)
{
this._config = config;
this._logger = logger;
}

public Token? AuthenticateToken(ListenerContext request, Lazy<IDatabaseContext> db)
Expand Down Expand Up @@ -71,6 +75,23 @@ public GameAuthenticationProvider(GameServerConfig? config)
// we don't actually receive tokens in endpoints (except during logout, aka token revocation)
if ((this._config?.MaintenanceMode ?? false) && user.Role != GameUserRole.Admin)
return null;

// Additional validation of the token gotten from DB. Exceptions will be caught, logged and InternalServerError will be returned automatically.
if (token.TokenData != tokenData)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"{typeof(GameAuthenticationProvider)} - Token from DB does not match token received from client!");
}

if (token.User.UserId != token.UserId)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"{typeof(GameAuthenticationProvider)} - GameUser included with token is not the token owner!");
}

return token;
}
Expand Down
6 changes: 3 additions & 3 deletions Refresh.GameServer/RefreshGameServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public RefreshGameServer(

this.WorkerManager?.Stop();

authProvider ??= new GameAuthenticationProvider(this._configStore.GameServer);
authProvider ??= new GameAuthenticationProvider(this._configStore.GameServer, this.Logger);

this.InjectBaseServices(provider, authProvider, this._dataStore);
});
Expand All @@ -107,7 +107,7 @@ protected virtual ConfigStore CreateConfigStore()
private void InjectBaseServices(GameDatabaseProvider databaseProvider, IAuthenticationProvider<Token> authProvider, IDataStore dataStore)
{
this.Server.UseDatabaseProvider(databaseProvider);
this.Server.AddAuthenticationService(authProvider, true);
this.Server.AddService(new GameAuthenticationService(this.Server.Logger, authProvider));
this.Server.AddStorageService(dataStore);
}

Expand Down Expand Up @@ -141,7 +141,7 @@ protected override void SetupConfiguration()
protected override void SetupServices()
{
this.Server.AddService<TimeProviderService>(this.GetTimeProvider());
this.Server.AddRateLimitService(new RateLimitSettings(90, 380, 45, "global"));
this.Server.AddService<GameRateLimitService>(new RateLimiter(new RateLimitSettings(90, 380, 45, "global")));
this.Server.AddService<CategoryService>();
this.Server.AddService(new MatchService(this.Server.Logger, this._configStore.GameServer));
this.Server.AddService<ImportService>();
Expand Down
25 changes: 24 additions & 1 deletion Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Net;
using AttribDoc.Attributes;
using Bunkum.Core;
Expand Down Expand Up @@ -105,6 +106,21 @@ public ApiResponse<IApiAuthenticationResponse> Authenticate(RequestContext conte

Token token = database.GenerateTokenForUser(user, TokenType.Api, TokenGame.Website, TokenPlatform.Website, ipAddress);
Token refreshToken = database.GenerateTokenForUser(user, TokenType.ApiRefresh, TokenGame.Website, TokenPlatform.Website, ipAddress, GameDatabaseContext.RefreshTokenExpirySeconds);

if (user.UserId != token.UserId)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"API login - API token owner ({token.User}) does not match user received from DB ({user})!");
}
if (user.UserId != refreshToken.UserId)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"API login - Refresh token owner ({refreshToken.User}) does not match user received from DB ({user})!");
}

context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully logged in through the API");

Expand All @@ -131,8 +147,15 @@ public ApiResponse<IApiAuthenticationResponse> RefreshToken(RequestContext conte
GameUser user = refreshToken.User;

Token token = database.GenerateTokenForUser(user, TokenType.Api, TokenGame.Website, TokenPlatform.Website, context.RemoteIp());
if (token.UserId != refreshToken.UserId)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"RefreshToken - Owner of new token ({token.User}) does not match owner of refresh token ({refreshToken.User})!");
}

database.ResetApiRefreshTokenExpiry(refreshToken);

context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully refreshed their token through the API");

return new ApiAuthenticationResponse
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Xml.Serialization;
using Bunkum.Core;
using Bunkum.Core.Endpoints;
Expand Down Expand Up @@ -70,6 +71,15 @@ public class AuthenticationEndpoints : EndpointGroup

user = database.CreateUserFromQueuedRegistration(registration, platform);

// Additional check to prevent spamming random users with emails
if (user.Username != ticket.Username)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"Game login - Username {user.Username} from registration does not match username {ticket.Username} in NP ticket!");
}

if (integrationConfig.SmtpEnabled)
{
EmailVerificationCode code = database.CreateEmailVerificationCode(user);
Expand Down Expand Up @@ -188,6 +198,14 @@ public class AuthenticationEndpoints : EndpointGroup
context.Logger.LogWarning(BunkumCategory.Authentication, $"{ticket.Username}'s Patchwork version is invalid: {context.RequestHeaders["User-Agent"]}");
return null;
}

if (user.Username != ticket.Username)
{
#if DEBUG
if(Debugger.IsAttached) Debugger.Break();
#endif
throw new InvalidDataException($"Game login - Username {user.Username} from DB GameUser does not match username {ticket.Username} in NP ticket!");
}

// !!
// Past this point, login is considered to be complete.
Expand Down
Loading