forked from LittleBigRefresh/Refresh
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAuthenticationApiEndpoints.cs
More file actions
448 lines (370 loc) · 21.3 KB
/
Copy pathAuthenticationApiEndpoints.cs
File metadata and controls
448 lines (370 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
using System.Diagnostics;
using System.Net;
using AttribDoc.Attributes;
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.RateLimit;
using Bunkum.Protocols.Http;
using Refresh.Common;
using Refresh.Common.Time;
using Refresh.Common.Verification;
using Refresh.Core.Authentication.Permission;
using Refresh.Core.Configuration;
using Refresh.Core.Services;
using Refresh.Core.Types.Data;
using Refresh.Database;
using Refresh.Database.Models.Authentication;
using Refresh.Database.Models.Pins;
using Refresh.Database.Models.Relations;
using Refresh.Database.Models.Users;
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes;
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request.Authentication;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
using Refresh.Interfaces.APIv3.Extensions;
namespace Refresh.Interfaces.APIv3.Endpoints;
using BC = BCrypt.Net.BCrypt;
public class AuthenticationApiEndpoints : EndpointGroup
{
/// <summary>
/// How many rounds to do for password hashing (BCrypt)
/// On my machine, a work factor of 14 takes roughly 1 second for password checks.
/// </summary>
/// <remarks>
/// If increased, passwords will automatically be rehashed at login time to use the new WorkFactor
/// If decreased, passwords will stay at higher WorkFactor until reset
/// </remarks>
public const int WorkFactor = 14;
/// <summary>
/// A randomly generated password.
/// Used to prevent against timing attacks.
/// </summary>
private static readonly string FakePassword = BC.HashPassword(Random.Shared.Next().ToString(), WorkFactor);
[ApiV3Endpoint("login", HttpMethods.Post), Authentication(false), AllowDuringMaintenance]
[DocRequestBody(typeof(ApiAuthenticationRequest))]
[RateLimitSettings(300, 10, 300, "auth")]
public ApiResponse<IApiAuthenticationResponse> Authenticate(RequestContext context, GameDatabaseContext database, ApiAuthenticationRequest body, GameServerConfig config)
{
if (!config.PermitWebLogin || !config.PermitAllLogins)
{
return new ApiAuthenticationError(
"The server is not allowing website logins right now.");
}
if (database.IsEmailQueued(body.EmailAddress))
return UserInQueueError();
GameUser? user = database.GetUserByEmailAddress(body.EmailAddress);
if (user == null)
{
// Do the work of checking the password if there was no user found.
// If we immediately return when we can't find a user, then it will be a short-lived request.
// If we find a user and we check the password, then the request will take much longer.
//
// You can use this discrepancy to determine if a given email is valid.
// Thus, we should always do the work of checking the password.
_ = BC.Verify(body.PasswordSha512, FakePassword);
return new ApiAuthenticationError("The email or password was incorrect.");
}
if (config.MaintenanceMode && user.Role != GameUserRole.Admin)
return new ApiAuthenticationError(
"The server is currently in maintenance mode, so it is only accessible for administrators. " +
"Check back later.");
string ipAddress = context.RemoteIp();
// if this is a legacy user, have them create a password on login
if (user.PasswordBcrypt == null)
{
Token resetToken = database.GenerateTokenForUser(user, TokenType.PasswordReset, TokenGame.Website, TokenPlatform.Website, ipAddress);
return new ApiResetPasswordResponse
{
Reason = "The account you are trying to sign into is a legacy account. Please set a password.",
ResetToken = resetToken.TokenData,
};
}
if (!BC.Verify(body.PasswordSha512, user.PasswordBcrypt))
return new ApiAuthenticationError("The email or password was incorrect.");
if (BC.PasswordNeedsRehash(user.PasswordBcrypt, WorkFactor))
database.SetUserPassword(user, BC.HashPassword(body.PasswordSha512, WorkFactor));
if (user.Role == GameUserRole.Banned)
return new ApiAuthenticationError($"You are banned until {user.BanExpiryDate.ToString()}. " +
$"For more information or to request account deletion, please contact the server administrator.\n" +
$"Reason: {user.BanReason}");
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");
// Update pin progress for signing into the API
database.IncrementUserPinProgress((long)ServerPins.SignIntoWebsite, 1, user, false, TokenPlatform.Website);
return new ApiAuthenticationResponse
{
RefreshTokenData = refreshToken.TokenData,
TokenData = token.TokenData,
UserId = user.UserId.ToString(),
ExpiresAt = token.ExpiresAt,
};
}
[ApiV3Endpoint("refreshToken", HttpMethods.Post), Authentication(false), AllowDuringMaintenance]
[DocRequestBody(typeof(ApiRefreshRequest))]
[RateLimitSettings(300, 10, 300, "auth")]
public ApiResponse<IApiAuthenticationResponse> RefreshToken(RequestContext context, GameDatabaseContext database, ApiRefreshRequest body)
{
Token? refreshToken = database.GetTokenFromTokenData(body.TokenData, TokenType.ApiRefresh);
if (refreshToken == null) return new ApiAuthenticationError("Your session has expired, please sign in again.");
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
{
RefreshTokenData = null,
TokenData = token.TokenData,
UserId = user.UserId.ToString(),
ExpiresAt = token.ExpiresAt,
};
}
[ApiV3Endpoint("resetPassword", HttpMethods.Put), Authentication(false)]
[RateLimitSettings(300, 10, 300, "auth")]
public ApiOkResponse ResetPassword(RequestContext context, GameDatabaseContext database, ApiResetPasswordRequest body, GameUser? user)
{
user ??= database.GetUserFromTokenData(body.ResetToken, TokenType.PasswordReset);
if (user == null) return new ApiAuthenticationError("The reset token is invalid");
if (body.PasswordSha512.Length != 128 || !CommonPatterns.Sha512Regex().IsMatch(body.PasswordSha512))
return new ApiValidationError("Password is definitely not SHA512. Please hash the password.");
string? passwordBcrypt = BC.HashPassword(body.PasswordSha512, WorkFactor);
if (passwordBcrypt == null) return new ApiInternalError("Could not BCrypt the given password.");
database.SetUserPassword(user, passwordBcrypt);
database.RevokeTokenByTokenData(body.ResetToken, TokenType.PasswordReset);
context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully reset their password");
return new ApiOkResponse();
}
[ApiV3Endpoint("sendPasswordResetEmail", HttpMethods.Put), Authentication(false)]
[RateLimitSettings(86400 / 2, 5, 86400, "resetPassword")]
public ApiOkResponse SendPasswordResetEmail(RequestContext context,
GameDatabaseContext database,
ApiSendPasswordResetEmailRequest body,
SmtpService smtpService)
{
if (database.IsEmailQueued(body.EmailAddress))
return UserInQueueError();
GameUser? user = database.GetUserByEmailAddress(body.EmailAddress);
if (user == null)
{
context.Logger.LogWarning(RefreshContext.PasswordReset, "Couldn't find a user by the email '{0}', not sending email", body.EmailAddress);
// return a fake success on purpose
return new ApiOkResponse();
}
context.Logger.LogInfo(RefreshContext.PasswordReset, "Sending a password reset request email to {0}.", user.Username);
Token token = database.GenerateTokenForUser(user, TokenType.PasswordReset, TokenGame.Website, TokenPlatform.Website, context.RemoteIp());
context.Logger.LogTrace(RefreshContext.PasswordReset, "Reset token: {0}", token.TokenData);
smtpService.SendPasswordResetRequest(user, token.TokenData);
context.Logger.LogInfo(RefreshContext.PasswordReset, "Email sent, token will expire at {0}", token.ExpiresAt);
return new ApiOkResponse();
}
[ApiV3Endpoint("logout", HttpMethods.Put), MinimumRole(GameUserRole.Restricted)]
[DocSummary("Tells the server to revoke the token used to make this request. Useful for logout behavior.")]
public ApiOkResponse RevokeThisToken(RequestContext context, GameDatabaseContext database, Token token)
{
context.Logger.LogInfo(BunkumCategory.Authentication, $"{token.User} logged out");
database.RevokeToken(token);
return new ApiOkResponse();
}
private const int IpVerificationTimeoutDuration = 300;
private const int IpVerificationListAmount = 12;
private const int IpVerificationActionAmount = 18;
private const int IpVerificationBlockDuration = 240;
private const string IpVerificationListBucket = "ip-verification-list";
private const string IpVerificationActionBucket = "ip-verification-action";
// IP Verification
[ApiV3Endpoint("verificationRequests"), MinimumRole(GameUserRole.Restricted)]
[DocSummary("Retrieves a list of IP addresses that have attempted to connect.")]
[RateLimitSettings(IpVerificationTimeoutDuration, IpVerificationListAmount, IpVerificationBlockDuration, IpVerificationListBucket)]
public ApiListResponse<ApiGameIpVerificationRequestResponse> GetVerificationRequests(RequestContext context,
GameDatabaseContext database, GameUser user, DataContext dataContext)
{
(int skip, int count) = context.GetPageData();
return DatabaseListExtensions.FromOldList<ApiGameIpVerificationRequestResponse, GameIpVerificationRequest>
(database.GetIpVerificationRequestsForUser(user, count, skip), dataContext);
}
[ApiV3Endpoint("verifiedIps"), MinimumRole(GameUserRole.Restricted)]
[DocSummary("Retrieves the list of IP addresses that have been verified by the logged in user.")]
[RateLimitSettings(IpVerificationTimeoutDuration, IpVerificationListAmount, IpVerificationBlockDuration, IpVerificationListBucket)]
public ApiListResponse<ApiGameUserVerifiedIpResponse> GetVerifiedIps(RequestContext context,
GameDatabaseContext database, DataContext dataContext, GameUser user)
{
(int skip, int count) = context.GetPageData();
DatabaseList<GameUserVerifiedIpRelation> verifiedIps = database.GetVerifiedIps(user, skip, count);
return DatabaseListExtensions.FromOldList<ApiGameUserVerifiedIpResponse, GameUserVerifiedIpRelation>(verifiedIps, dataContext);
}
[ApiV3Endpoint("removeVerifiedIp", HttpMethods.Delete), MinimumRole(GameUserRole.Restricted)]
[DocSummary("Removes the specified IP from the list of approved IP addresses")]
[DocError(typeof(ApiValidationError), ApiValidationError.IpAddressParseErrorWhen)]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.VerifiedIpMissingErrorWhen)]
[DocRequestBody("127.0.0.1")]
[RateLimitSettings(IpVerificationTimeoutDuration, IpVerificationActionAmount, IpVerificationBlockDuration, IpVerificationActionBucket)]
public ApiOkResponse RemoveVerifiedIp(
RequestContext context,
GameDatabaseContext database,
GameUser user,
string body)
{
string ipAddress = body.Trim();
if (!IPAddress.TryParse(ipAddress, out _))
return ApiValidationError.IpAddressParseError;
if (!database.RemoveVerifiedIp(user, ipAddress))
return ApiNotFoundError.VerifiedIpMissingError;
return new ApiOkResponse();
}
[ApiV3Endpoint("verificationRequests/approve", HttpMethods.Put), MinimumRole(GameUserRole.Restricted)]
[DocSummary("Approves a given IP, and clears all remaining verification requests. Send the IP in the body.")]
[DocError(typeof(ApiValidationError), ApiValidationError.IpAddressParseErrorWhen)]
[DocRequestBody("127.0.0.1")]
[RateLimitSettings(IpVerificationTimeoutDuration, IpVerificationActionAmount, IpVerificationBlockDuration, IpVerificationActionBucket)]
public ApiOkResponse ApproveVerificationRequest(
RequestContext context,
GameDatabaseContext database,
IDateTimeProvider timeProvider,
GameUser user,
string body)
{
string ipAddress = body.Trim();
if (!IPAddress.TryParse(ipAddress, out _))
return ApiValidationError.IpAddressParseError;
if (!database.IsIpVerified(user, ipAddress))
database.AddVerifiedIp(user, ipAddress, timeProvider);
return new ApiOkResponse();
}
[ApiV3Endpoint("verificationRequests/deny", HttpMethods.Put)]
[DocSummary("Denies all verification requests matching a given IP. Send the IP in the body.")]
[DocError(typeof(ApiValidationError), ApiValidationError.IpAddressParseErrorWhen)]
[DocRequestBody("127.0.0.1")]
[RateLimitSettings(IpVerificationTimeoutDuration, IpVerificationActionAmount, IpVerificationBlockDuration, IpVerificationActionBucket)]
public ApiOkResponse DenyVerificationRequest(RequestContext context, GameDatabaseContext database, GameUser user, string body)
{
string ipAddress = body.Trim();
if (!IPAddress.TryParse(ipAddress, out _))
return ApiValidationError.IpAddressParseError;
database.DenyIpVerificationRequest(user, ipAddress);
return new ApiOkResponse();
}
[ApiV3Endpoint("register", HttpMethods.Post), Authentication(false)]
[DocSummary("Registers a new user.")]
[DocError(typeof(ApiValidationError), ApiValidationError.InvalidUsernameErrorWhen)]
[DocRequestBody(typeof(ApiRegisterRequest))]
#if !DEBUG
[RateLimitSettings(3600, 10, 3600 / 2, "register")]
#endif
public ApiResponse<IApiAuthenticationResponse> Register(RequestContext context,
GameDatabaseContext database,
ApiRegisterRequest body,
GameServerConfig config,
IntegrationConfig integrationConfig,
SmtpService smtpService)
{
if (!config.RegistrationEnabled)
return new ApiAuthenticationError("Registration is not enabled on this server. Check back later.");
if (body.PasswordSha512.Length != 128 || !CommonPatterns.Sha512Regex().IsMatch(body.PasswordSha512))
return new ApiValidationError("Password is definitely not SHA512. Please hash the password.");
if (!CommonPatterns.EmailAddressRegex().IsMatch(body.EmailAddress))
return new ApiValidationError("The email address given is invalid. Did you type it correctly?");
if (!smtpService.CheckEmailDomainValidity(body.EmailAddress))
return ApiValidationError.EmailDoesNotActuallyExistError;
if (database.IsUserDisallowed(body.Username) || database.IsEmailAddressDisallowed(body.EmailAddress) || database.IsEmailDomainDisallowed(body.EmailAddress))
return new ApiAuthenticationError("You aren't allowed to play on this instance.");
if (!database.IsUsernameValid(body.Username))
return new ApiValidationError(ApiValidationError.InvalidUsernameErrorWhen
+ " Are you sure you used your PSN/RPCN username?");
if (database.IsUsernameQueued(body.Username) || database.IsEmailQueued(body.EmailAddress))
return UserInQueueError();
if (database.IsUsernameTaken(body.Username) || database.IsEmailTaken(body.EmailAddress))
{
return new ApiAuthenticationError(
"The account could not be registered because username or email was already taken. " +
(config.RequireGameLoginToRegister ? "If you have already registered, try signing in via the game to activate your account." : ""));
}
string? passwordBcrypt = BC.HashPassword(body.PasswordSha512, WorkFactor);
if (passwordBcrypt == null) return new ApiInternalError("Could not BCrypt the given password.");
if (config.RequireGameLoginToRegister)
{
database.AddRegistrationToQueue(body.Username, body.EmailAddress, passwordBcrypt);
return new ApiAuthenticationError(
"Account queued! We are now waiting for you to connect. " +
"Play a patched game online within the next hour to permanently register. " +
"If you wait longer than an hour, you’ll have to redo this process to reenter the queue. " +
"For more instructions on patching, please visit https://docs.lbpbonsai.com", true);
}
GameUser user = database.CreateUser(body.Username, body.EmailAddress, true);
database.SetUserPassword(user, passwordBcrypt);
if (integrationConfig.SmtpEnabled)
{
EmailVerificationCode code = database.CreateEmailVerificationCode(user);
smtpService.SendEmailVerificationRequest(user, code.Code);
}
else
{
// if smtp isn't enabled just mark the user's email as verified
database.VerifyUserEmail(user);
}
Token token = database.GenerateTokenForUser(user, TokenType.Api, TokenGame.Website, TokenPlatform.Website, context.RemoteIp());
return new ApiAuthenticationResponse
{
TokenData = token.TokenData,
UserId = user.UserId.ToString(),
ExpiresAt = token.ExpiresAt,
};
}
[ApiV3Endpoint("verify", HttpMethods.Post)]
[DocSummary("Verifies an email address using the given code")]
public ApiOkResponse VerifyEmail(RequestContext context, GameUser user, GameDatabaseContext database)
{
string? code = context.QueryString.Get("code");
if (code == null) return new ApiValidationError("The code parameter was not found or invalid");
if (!database.VerificationCodeMatches(user, code.Trim())) return ApiNotFoundError.Instance;
database.VerifyUserEmail(user);
return new ApiOkResponse();
}
[ApiV3Endpoint("verify/resend", HttpMethods.Post)]
[DocSummary("Instructs the server to resend the verification email with a new code")]
public ApiOkResponse ResendVerificationCode(RequestContext context, GameUser user, GameDatabaseContext database, SmtpService smtpService)
{
EmailVerificationCode code = database.CreateEmailVerificationCode(user);
smtpService.SendEmailVerificationRequest(user, code.Code);
return new ApiOkResponse();
}
[ApiV3Endpoint("users/me", HttpMethods.Delete), MinimumRole(GameUserRole.Restricted)]
[DocSummary("Deletes your own account. This action is non-reversible. This endpoint now requires you to include your own password while being authenticated.")]
public ApiOkResponse DeleteMyAccount(RequestContext context, GameUser user, ApiOwnUserDeletionRequest body, GameDatabaseContext database)
{
if (string.IsNullOrWhiteSpace(body.PasswordSha512))
return new ApiValidationError("You must enter your password to delete your account.");
if (!BC.Verify(body.PasswordSha512, user.PasswordBcrypt))
return new ApiValidationError("The password was incorrect.");
database.DeleteUser(user);
return new ApiOkResponse();
}
private static ApiAuthenticationError UserInQueueError()
{
return new ApiAuthenticationError(
"Your account is in the registration queue, and we are waiting for you to connect. " +
"To do so you must patch your game to our servers. " +
"For more instructions on patching, please visit https://docs.lbpbonsai.com", true);
}
}