Skip to content

Commit 4f1aa5c

Browse files
authored
Don't cache tokens, additional auth validation (#1052)
Replaces Bunkum's `AuthenticationService` with an implementation which does not cache tokens per thread, and also adds validation of tokens received from DB to certain methods to try and avoid weird, unreproducible issues from causing damage.
2 parents fe8bc3b + f511723 commit 4f1aa5c

11 files changed

Lines changed: 193 additions & 16 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Reflection;
2+
using Bunkum.Core;
3+
using Bunkum.Core.Authentication;
4+
using Bunkum.Core.Database;
5+
using Bunkum.Core.Endpoints;
6+
using Bunkum.Core.Responses;
7+
using Bunkum.Core.Services;
8+
using Bunkum.Listener.Protocol;
9+
using Bunkum.Listener.Request;
10+
using NotEnoughLogs;
11+
using Refresh.Database.Models.Authentication;
12+
13+
namespace Refresh.Core.Services;
14+
15+
// Referenced from https://github.com/PlanetBunkum/Bunkum/blob/main/Bunkum.Core/Services/AuthenticationService.cs
16+
// purposefully a less optimized implementation (as in it doesn't cache the token)
17+
public class GameAuthenticationService : Service
18+
{
19+
private readonly IAuthenticationProvider<Token> _provider;
20+
21+
public GameAuthenticationService(Logger logger, IAuthenticationProvider<Token> provider) : base(logger)
22+
{
23+
this._provider = provider;
24+
}
25+
26+
public override Response? OnRequestHandled(ListenerContext context, MethodInfo method, Lazy<IDatabaseContext> database)
27+
{
28+
if (!(method.GetCustomAttribute<AuthenticationAttribute>()?.Required ?? true)) return null;
29+
30+
if (this.AuthenticateToken(context, database) == null)
31+
return new Response("Not authenticated", ContentType.Plaintext, Forbidden);
32+
33+
return null;
34+
}
35+
36+
public override object? AddParameterToEndpoint(ListenerContext context, BunkumParameterInfo parameter, Lazy<IDatabaseContext> database)
37+
{
38+
if (ParameterBasedFrom<IToken<IUser>>(parameter))
39+
{
40+
return this.AuthenticateToken(context, database);
41+
}
42+
43+
if (ParameterBasedFrom<IUser>(parameter))
44+
{
45+
IToken<IUser>? token = this.AuthenticateToken(context, database);
46+
if (token != null) return token.User;
47+
}
48+
49+
return null;
50+
}
51+
52+
public Token? AuthenticateToken(ListenerContext context, Lazy<IDatabaseContext> database)
53+
{
54+
return this._provider.AuthenticateToken(context, database);
55+
}
56+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System.Reflection;
2+
using Bunkum.Core.Database;
3+
using Bunkum.Core.RateLimit;
4+
using Bunkum.Core.Responses;
5+
using Bunkum.Core.Services;
6+
using Bunkum.Listener.Protocol;
7+
using Bunkum.Listener.Request;
8+
using NotEnoughLogs;
9+
using Refresh.Database.Models.Users;
10+
11+
namespace Refresh.Core.Services;
12+
13+
// Referenced from https://github.com/PlanetBunkum/Bunkum/blob/main/Bunkum.Core/Services/RateLimitService.cs
14+
public class GameRateLimitService : Service
15+
{
16+
private readonly IRateLimiter _rateLimiter;
17+
private readonly GameAuthenticationService _authService;
18+
19+
internal GameRateLimitService(Logger logger, GameAuthenticationService authService, IRateLimiter rateLimiter) : base(logger)
20+
{
21+
this._rateLimiter = rateLimiter;
22+
this._authService = authService;
23+
}
24+
25+
public override Response? OnRequestHandled(ListenerContext context, MethodInfo method, Lazy<IDatabaseContext> database)
26+
{
27+
GameUser? user = this._authService.AuthenticateToken(context, database)?.User;
28+
29+
bool violated = false;
30+
31+
if (user != null)
32+
violated = this._rateLimiter.UserViolatesRateLimit(context, method, user);
33+
else
34+
violated = this._rateLimiter.RemoteEndpointViolatesRateLimit(context, method);
35+
36+
if (violated) return new Response("You have been rate-limited.", ContentType.Plaintext, TooManyRequests);
37+
return null;
38+
}
39+
}

Refresh.Core/Services/RoleService.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
namespace Refresh.Core.Services;
1313

1414
/// <summary>
15-
/// A service that hooks into the AuthenticationService, adding extra checks for roles.
15+
/// A service that hooks into the GameAuthenticationService, adding extra checks for roles.
1616
/// </summary>
1717
public class RoleService : Service
1818
{
19-
private readonly AuthenticationService _authService;
19+
private readonly GameAuthenticationService _authService;
2020
private readonly GameServerConfig _config;
2121

22-
internal RoleService(AuthenticationService authService, GameServerConfig config, Logger logger) : base(logger)
22+
internal RoleService(GameAuthenticationService authService, GameServerConfig config, Logger logger) : base(logger)
2323
{
2424
this._authService = authService;
2525
this._config = config;
@@ -39,14 +39,12 @@ internal RoleService(AuthenticationService authService, GameServerConfig config,
3939
// if the user's role is lower than the minimum role for this endpoint, then return unauthorized
4040
if (user.Role < minimumRole)
4141
{
42-
this._authService.RemoveTokenFromCache();
4342
return Unauthorized;
4443
}
4544

4645
RequireEmailVerifiedAttribute? emailAttrib = method.GetCustomAttribute<RequireEmailVerifiedAttribute>();
4746
if (emailAttrib != null && !user.EmailAddressVerified)
4847
{
49-
this._authService.RemoveTokenFromCache();
5048
return Unauthorized;
5149
}
5250

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

Refresh.Core/Types/Data/DataContextService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ public class DataContextService : Service
1414
{
1515
private readonly StorageService _storageService;
1616
private readonly MatchService _matchService;
17-
private readonly AuthenticationService _authService;
17+
private readonly GameAuthenticationService _authService;
1818
private readonly GuidCheckerService _guidCheckerService;
1919
private readonly CacheService _cacheService;
2020

21-
public DataContextService(StorageService storage, MatchService match, AuthenticationService auth, Logger logger, GuidCheckerService guidChecker, CacheService cache) : base(logger)
21+
public DataContextService(StorageService storage, MatchService match, GameAuthenticationService auth, Logger logger, GuidCheckerService guidChecker, CacheService cache) : base(logger)
2222
{
2323
this._storageService = storage;
2424
this._matchService = match;

Refresh.Database/GameDatabaseContext.Tokens.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Refresh.Database.Models.Authentication;
55
using Refresh.Database.Models.Users;
66
using Refresh.Database.Models.Relations;
7+
using System.Diagnostics;
78

89
namespace Refresh.Database;
910

@@ -84,6 +85,14 @@ public Token GenerateTokenForUser(GameUser user, TokenType type, TokenGame game,
8485
return null;
8586
}
8687

88+
if (token.TokenData != tokenData || token.TokenType != type)
89+
{
90+
#if DEBUG
91+
if(Debugger.IsAttached) Debugger.Break();
92+
#endif
93+
throw new InvalidDataException($"GetTokenFromTokenData - Token data or type does not match!");
94+
}
95+
8796
return token;
8897
}
8998

Refresh.Database/GameDatabaseContext.Users.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Refresh.Database.Models.Levels;
1010
using Refresh.Database.Models.Photos;
1111
using Refresh.Database.Models.Assets;
12+
using System.Diagnostics;
1213

1314
namespace Refresh.Database;
1415

@@ -64,8 +65,20 @@ public partial class GameDatabaseContext // Users
6465
public GameUser? GetUserByEmailAddress(string? emailAddress)
6566
{
6667
if (emailAddress == null) return null;
68+
6769
emailAddress = emailAddress.ToLowerInvariant();
68-
return this.GameUsersIncluded.FirstOrDefault(u => u.EmailAddress == emailAddress);
70+
GameUser? user = this.GameUsersIncluded.FirstOrDefault(u => u.EmailAddress == emailAddress);
71+
if (user == null) return null;
72+
73+
if (user.EmailAddress != emailAddress)
74+
{
75+
#if DEBUG
76+
if(Debugger.IsAttached) Debugger.Break();
77+
#endif
78+
throw new InvalidDataException($"GetUserByEmailAddress - Found user's email does not match given email!");
79+
}
80+
81+
return user;
6982
}
7083

7184
[Pure]

Refresh.Database/Models/Authentication/Token.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ public partial class Token : IToken<GameUser>
2828
[Required]
2929
public string IpAddress { get; set; }
3030

31-
[Required]
32-
public GameUser User { get; set; }
31+
[ForeignKey(nameof(UserId))]
32+
[Required] public GameUser User { get; set; }
33+
[Required] public ObjectId UserId { get; set; }
3334

3435
/// <summary>
3536
/// The digest key to use with this token, determined from the first game request created by this token

Refresh.GameServer/Authentication/GameAuthenticationProvider.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
using Refresh.Interfaces.APIv3;
1010
using Refresh.Interfaces.Game;
1111
using Refresh.Interfaces.Internal;
12+
using NotEnoughLogs;
13+
using Bunkum.Core;
1214

1315
namespace Refresh.GameServer.Authentication;
1416

1517
public class GameAuthenticationProvider : IAuthenticationProvider<Token>
1618
{
1719
private readonly GameServerConfig? _config;
20+
private readonly Logger _logger;
1821

19-
public GameAuthenticationProvider(GameServerConfig? config)
22+
public GameAuthenticationProvider(GameServerConfig? config, Logger logger)
2023
{
2124
this._config = config;
25+
this._logger = logger;
2226
}
2327

2428
public Token? AuthenticateToken(ListenerContext request, Lazy<IDatabaseContext> db)
@@ -71,6 +75,23 @@ public GameAuthenticationProvider(GameServerConfig? config)
7175
// we don't actually receive tokens in endpoints (except during logout, aka token revocation)
7276
if ((this._config?.MaintenanceMode ?? false) && user.Role != GameUserRole.Admin)
7377
return null;
78+
79+
// Additional validation of the token gotten from DB. Exceptions will be caught, logged and InternalServerError will be returned automatically.
80+
if (token.TokenData != tokenData)
81+
{
82+
#if DEBUG
83+
if(Debugger.IsAttached) Debugger.Break();
84+
#endif
85+
throw new InvalidDataException($"{typeof(GameAuthenticationProvider)} - Token from DB does not match token received from client!");
86+
}
87+
88+
if (token.User.UserId != token.UserId)
89+
{
90+
#if DEBUG
91+
if(Debugger.IsAttached) Debugger.Break();
92+
#endif
93+
throw new InvalidDataException($"{typeof(GameAuthenticationProvider)} - GameUser included with token is not the token owner!");
94+
}
7495

7596
return token;
7697
}

Refresh.GameServer/RefreshGameServer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public RefreshGameServer(
9393

9494
this.WorkerManager?.Stop();
9595

96-
authProvider ??= new GameAuthenticationProvider(this._configStore.GameServer);
96+
authProvider ??= new GameAuthenticationProvider(this._configStore.GameServer, this.Logger);
9797

9898
this.InjectBaseServices(provider, authProvider, this._dataStore);
9999
});
@@ -107,7 +107,7 @@ protected virtual ConfigStore CreateConfigStore()
107107
private void InjectBaseServices(GameDatabaseProvider databaseProvider, IAuthenticationProvider<Token> authProvider, IDataStore dataStore)
108108
{
109109
this.Server.UseDatabaseProvider(databaseProvider);
110-
this.Server.AddAuthenticationService(authProvider, true);
110+
this.Server.AddService(new GameAuthenticationService(this.Server.Logger, authProvider));
111111
this.Server.AddStorageService(dataStore);
112112
}
113113

@@ -141,7 +141,7 @@ protected override void SetupConfiguration()
141141
protected override void SetupServices()
142142
{
143143
this.Server.AddService<TimeProviderService>(this.GetTimeProvider());
144-
this.Server.AddRateLimitService(new RateLimitSettings(90, 380, 45, "global"));
144+
this.Server.AddService<GameRateLimitService>(new RateLimiter(new RateLimitSettings(90, 380, 45, "global")));
145145
this.Server.AddService<CategoryService>();
146146
this.Server.AddService(new MatchService(this.Server.Logger, this._configStore.GameServer));
147147
this.Server.AddService<ImportService>();

Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Net;
23
using AttribDoc.Attributes;
34
using Bunkum.Core;
@@ -105,6 +106,21 @@ public ApiResponse<IApiAuthenticationResponse> Authenticate(RequestContext conte
105106

106107
Token token = database.GenerateTokenForUser(user, TokenType.Api, TokenGame.Website, TokenPlatform.Website, ipAddress);
107108
Token refreshToken = database.GenerateTokenForUser(user, TokenType.ApiRefresh, TokenGame.Website, TokenPlatform.Website, ipAddress, GameDatabaseContext.RefreshTokenExpirySeconds);
109+
110+
if (user.UserId != token.UserId)
111+
{
112+
#if DEBUG
113+
if(Debugger.IsAttached) Debugger.Break();
114+
#endif
115+
throw new InvalidDataException($"API login - API token owner ({token.User}) does not match user received from DB ({user})!");
116+
}
117+
if (user.UserId != refreshToken.UserId)
118+
{
119+
#if DEBUG
120+
if(Debugger.IsAttached) Debugger.Break();
121+
#endif
122+
throw new InvalidDataException($"API login - Refresh token owner ({refreshToken.User}) does not match user received from DB ({user})!");
123+
}
108124

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

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

133149
Token token = database.GenerateTokenForUser(user, TokenType.Api, TokenGame.Website, TokenPlatform.Website, context.RemoteIp());
150+
if (token.UserId != refreshToken.UserId)
151+
{
152+
#if DEBUG
153+
if(Debugger.IsAttached) Debugger.Break();
154+
#endif
155+
throw new InvalidDataException($"RefreshToken - Owner of new token ({token.User}) does not match owner of refresh token ({refreshToken.User})!");
156+
}
157+
134158
database.ResetApiRefreshTokenExpiry(refreshToken);
135-
136159
context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully refreshed their token through the API");
137160

138161
return new ApiAuthenticationResponse

0 commit comments

Comments
 (0)