From 5531f2cce5c148b5399b6dfd090bb822e8d35a25 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Fri, 13 Mar 2026 20:20:56 +0100 Subject: [PATCH 1/4] Copy AuthenticationService and RateLimitService from Bunkum, modify them to not allow token caching --- .../Services/GameAuthenticationService.cs | 57 +++++++++++++++++++ Refresh.Core/Services/GameRateLimitService.cs | 40 +++++++++++++ Refresh.Core/Services/RoleService.cs | 9 +-- Refresh.Core/Types/Data/DataContextService.cs | 4 +- Refresh.GameServer/RefreshGameServer.cs | 4 +- 5 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 Refresh.Core/Services/GameAuthenticationService.cs create mode 100644 Refresh.Core/Services/GameRateLimitService.cs diff --git a/Refresh.Core/Services/GameAuthenticationService.cs b/Refresh.Core/Services/GameAuthenticationService.cs new file mode 100644 index 000000000..f44bfac24 --- /dev/null +++ b/Refresh.Core/Services/GameAuthenticationService.cs @@ -0,0 +1,57 @@ +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 _provider; + + public GameAuthenticationService(Logger logger, + IAuthenticationProvider provider) : base(logger) + { + this._provider = provider; + } + + public override Response? OnRequestHandled(ListenerContext context, MethodInfo method, Lazy database) + { + if (!(method.GetCustomAttribute()?.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 database) + { + if (ParameterBasedFrom>(parameter)) + { + return this.AuthenticateToken(context, database); + } + + if (ParameterBasedFrom(parameter)) + { + IToken? token = this.AuthenticateToken(context, database); + if (token != null) return token.User; + } + + return null; + } + + public Token? AuthenticateToken(ListenerContext context, Lazy database) + { + return this._provider.AuthenticateToken(context, database); + } +} \ No newline at end of file diff --git a/Refresh.Core/Services/GameRateLimitService.cs b/Refresh.Core/Services/GameRateLimitService.cs new file mode 100644 index 000000000..92b7ae82b --- /dev/null +++ b/Refresh.Core/Services/GameRateLimitService.cs @@ -0,0 +1,40 @@ +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 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; + } +} \ No newline at end of file diff --git a/Refresh.Core/Services/RoleService.cs b/Refresh.Core/Services/RoleService.cs index f31732f92..279ea2309 100644 --- a/Refresh.Core/Services/RoleService.cs +++ b/Refresh.Core/Services/RoleService.cs @@ -12,14 +12,14 @@ namespace Refresh.Core.Services; /// -/// A service that hooks into the AuthenticationService, adding extra checks for roles. +/// A service that hooks into the GameAuthenticationService, adding extra checks for roles. /// 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; @@ -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(); if (emailAttrib != null && !user.EmailAddressVerified) { - this._authService.RemoveTokenFromCache(); return Unauthorized; } @@ -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; } diff --git a/Refresh.Core/Types/Data/DataContextService.cs b/Refresh.Core/Types/Data/DataContextService.cs index 0757c7a5c..edc98e8da 100644 --- a/Refresh.Core/Types/Data/DataContextService.cs +++ b/Refresh.Core/Types/Data/DataContextService.cs @@ -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; diff --git a/Refresh.GameServer/RefreshGameServer.cs b/Refresh.GameServer/RefreshGameServer.cs index 17d432734..b35a759f7 100644 --- a/Refresh.GameServer/RefreshGameServer.cs +++ b/Refresh.GameServer/RefreshGameServer.cs @@ -107,7 +107,7 @@ protected virtual ConfigStore CreateConfigStore() private void InjectBaseServices(GameDatabaseProvider databaseProvider, IAuthenticationProvider 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); } @@ -141,7 +141,7 @@ protected override void SetupConfiguration() protected override void SetupServices() { this.Server.AddService(this.GetTimeProvider()); - this.Server.AddRateLimitService(new RateLimitSettings(90, 380, 45, "global")); + this.Server.AddService(new RateLimiter(new RateLimitSettings(90, 380, 45, "global"))); this.Server.AddService(); this.Server.AddService(new MatchService(this.Server.Logger, this._configStore.GameServer)); this.Server.AddService(); From 61463d0b6ef9f75f530fabfd269d5a6eaf8d5892 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Fri, 13 Mar 2026 20:52:43 +0100 Subject: [PATCH 2/4] Ensure token data received from DB is correct --- .../Services/GameAuthenticationService.cs | 3 +-- Refresh.Core/Services/GameRateLimitService.cs | 3 +-- .../Models/Authentication/Token.cs | 5 ++-- .../GameAuthenticationProvider.cs | 25 ++++++++++++++++++- Refresh.GameServer/RefreshGameServer.cs | 2 +- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Refresh.Core/Services/GameAuthenticationService.cs b/Refresh.Core/Services/GameAuthenticationService.cs index f44bfac24..fd2c82848 100644 --- a/Refresh.Core/Services/GameAuthenticationService.cs +++ b/Refresh.Core/Services/GameAuthenticationService.cs @@ -18,8 +18,7 @@ public class GameAuthenticationService : Service { private readonly IAuthenticationProvider _provider; - public GameAuthenticationService(Logger logger, - IAuthenticationProvider provider) : base(logger) + public GameAuthenticationService(Logger logger, IAuthenticationProvider provider) : base(logger) { this._provider = provider; } diff --git a/Refresh.Core/Services/GameRateLimitService.cs b/Refresh.Core/Services/GameRateLimitService.cs index 92b7ae82b..d02d3a3ef 100644 --- a/Refresh.Core/Services/GameRateLimitService.cs +++ b/Refresh.Core/Services/GameRateLimitService.cs @@ -16,8 +16,7 @@ public class GameRateLimitService : Service private readonly IRateLimiter _rateLimiter; private readonly GameAuthenticationService _authService; - internal GameRateLimitService(Logger logger, GameAuthenticationService authService, IRateLimiter rateLimiter) - : base(logger) + internal GameRateLimitService(Logger logger, GameAuthenticationService authService, IRateLimiter rateLimiter) : base(logger) { this._rateLimiter = rateLimiter; this._authService = authService; diff --git a/Refresh.Database/Models/Authentication/Token.cs b/Refresh.Database/Models/Authentication/Token.cs index eb14e3ba7..23a1d6bd0 100644 --- a/Refresh.Database/Models/Authentication/Token.cs +++ b/Refresh.Database/Models/Authentication/Token.cs @@ -28,8 +28,9 @@ public partial class Token : IToken [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; } /// /// The digest key to use with this token, determined from the first game request created by this token diff --git a/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs b/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs index a6320dfdd..d1fee0b17 100644 --- a/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs +++ b/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs @@ -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 { 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 db) @@ -71,6 +75,25 @@ 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 + if (token.TokenData != tokenData) + { +#if DEBUG + if(Debugger.IsAttached) Debugger.Break(); +#endif + this._logger.LogError(BunkumCategory.Authentication, $"Token from DB does not match token received from client! Rejecting..."); + return null; + } + + if (token.User.UserId != token.UserId) + { +#if DEBUG + if(Debugger.IsAttached) Debugger.Break(); +#endif + this._logger.LogError(BunkumCategory.Authentication, $"GameUser included with token is not the token owner! Rejecting..."); + return null; + } return token; } diff --git a/Refresh.GameServer/RefreshGameServer.cs b/Refresh.GameServer/RefreshGameServer.cs index b35a759f7..a78ba4cc3 100644 --- a/Refresh.GameServer/RefreshGameServer.cs +++ b/Refresh.GameServer/RefreshGameServer.cs @@ -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); }); From 67ef4c4e4b800510b6a8f860510ba91fe62a2e52 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Fri, 13 Mar 2026 20:59:02 +0100 Subject: [PATCH 3/4] Throw on invalid token data instead --- .../Authentication/GameAuthenticationProvider.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs b/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs index d1fee0b17..41f7cabe1 100644 --- a/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs +++ b/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs @@ -76,14 +76,13 @@ public GameAuthenticationProvider(GameServerConfig? config, Logger logger) if ((this._config?.MaintenanceMode ?? false) && user.Role != GameUserRole.Admin) return null; - // Additional validation of the token gotten from DB + // 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 - this._logger.LogError(BunkumCategory.Authentication, $"Token from DB does not match token received from client! Rejecting..."); - return null; + throw new InvalidDataException($"{typeof(GameAuthenticationProvider)} - Token from DB does not match token received from client!"); } if (token.User.UserId != token.UserId) @@ -91,8 +90,7 @@ public GameAuthenticationProvider(GameServerConfig? config, Logger logger) #if DEBUG if(Debugger.IsAttached) Debugger.Break(); #endif - this._logger.LogError(BunkumCategory.Authentication, $"GameUser included with token is not the token owner! Rejecting..."); - return null; + throw new InvalidDataException($"{typeof(GameAuthenticationProvider)} - GameUser included with token is not the token owner!"); } return token; From f5117236ebcaa0f8cda4b50c3b3168c7fc07384f Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Fri, 13 Mar 2026 21:49:53 +0100 Subject: [PATCH 4/4] Additional auth checks --- .../GameDatabaseContext.Tokens.cs | 9 +++++++ Refresh.Database/GameDatabaseContext.Users.cs | 15 ++++++++++- .../Endpoints/AuthenticationApiEndpoints.cs | 25 ++++++++++++++++++- .../Handshake/AuthenticationEndpoints.cs | 18 +++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Tokens.cs b/Refresh.Database/GameDatabaseContext.Tokens.cs index 13d1fa8a8..59a641640 100644 --- a/Refresh.Database/GameDatabaseContext.Tokens.cs +++ b/Refresh.Database/GameDatabaseContext.Tokens.cs @@ -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; @@ -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; } diff --git a/Refresh.Database/GameDatabaseContext.Users.cs b/Refresh.Database/GameDatabaseContext.Users.cs index ebf1e6b89..a1f836241 100644 --- a/Refresh.Database/GameDatabaseContext.Users.cs +++ b/Refresh.Database/GameDatabaseContext.Users.cs @@ -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; @@ -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] diff --git a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs index cc5eaa1ed..a2d87a3ba 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using AttribDoc.Attributes; using Bunkum.Core; @@ -105,6 +106,21 @@ public ApiResponse 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"); @@ -131,8 +147,15 @@ public ApiResponse 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 diff --git a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs index f38597006..621caa70c 100644 --- a/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Handshake/AuthenticationEndpoints.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Xml.Serialization; using Bunkum.Core; using Bunkum.Core.Endpoints; @@ -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); @@ -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.