Skip to content

Commit

Permalink
Enforce email verification (#586)
Browse files Browse the repository at this point in the history
- Adds a new `[RequireEmailVerified]` attribute that can be applied to
endpoints.
This uses the same system as `[MinimumRole]`, where the endpoint will
not be executed at all if the user does not have a verified email
address. It will simply return 401 Unauthorized instead.
- Hijacks `/announce` when the user doesn't have an email address,
informing them on how to verify.
- Does a basic DNS check against the email domain, preventing users from
registering/editing their email if the email server doesn't actually
exist.
To make this trivial, this brings in a NuGet package called
[`DnsClient`](https://dnsclient.michaco.net/). It's also optional, you
can disable this functionality behind a flag in `IntegrationConfig`.
  • Loading branch information
jvyden authored Jul 26, 2024
2 parents 4fab52a + 58cef35 commit 4638747
Show file tree
Hide file tree
Showing 21 changed files with 71 additions and 8 deletions.
3 changes: 2 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 => 3;
public override int CurrentConfigVersion => 4;
public override int Version { get; set; }
protected override void Migrate(int oldVer, dynamic oldConfig)
{
Expand All @@ -23,6 +23,7 @@ protected override void Migrate(int oldVer, dynamic oldConfig)
public bool SmtpTlsEnabled { get; set; } = true;
public string SmtpUsername { get; set; } = "[email protected]";
public string SmtpPassword { get; set; } = "P4$$w0rd";
public bool SmtpVerifyDomain { get; set; } = true;

#endregion

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ protected override void DocumentRouteHook(MethodInfo method, Route route)
{
MinimumRoleAttribute? roleAttribute = method.GetCustomAttribute<MinimumRoleAttribute>();
if (roleAttribute != null)
{
route.ExtraProperties["minimumRole"] = roleAttribute.MinimumRole;
}

RequireEmailVerifiedAttribute? emailAttribute = method.GetCustomAttribute<RequireEmailVerifiedAttribute>();
if(emailAttribute != null)
route.ExtraProperties["emailRequired"] = true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public class ApiValidationError : ApiError

public const string InvalidTextureGuidErrorWhen = "The passed GUID is not a valid texture GUID for the specified game.";
public static readonly ApiValidationError InvalidTextureGuidError = new(InvalidTextureGuidErrorWhen);

public const string EmailDoesNotActuallyExistErrorWhen = "The email address given does not exist. Are you sure you typed it in correctly?";
public static readonly ApiValidationError EmailDoesNotActuallyExistError = new(EmailDoesNotActuallyExistErrorWhen);

public ApiValidationError(string message) : base(message) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.RateLimit;
using Bunkum.Listener.Protocol;
using Bunkum.Protocols.Http;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Configuration;
Expand All @@ -12,7 +11,6 @@
using Refresh.GameServer.Endpoints.ApiV3.ApiTypes.Errors;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Request.Authentication;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response.Users;
using Refresh.GameServer.Extensions;
using Refresh.GameServer.Services;
Expand Down Expand Up @@ -244,6 +242,9 @@ public ApiResponse<IApiAuthenticationResponse> Register(RequestContext context,
if (!CommonPatterns.EmailAddressRegex().IsMatch(body.EmailAddress))
return new ApiValidationError("The email address given is invalid.");

if (!smtpService.CheckEmailDomainValidity(body.EmailAddress))
return ApiValidationError.EmailDoesNotActuallyExistError;

if (database.IsUserDisallowed(body.Username))
return new ApiAuthenticationError("This username is disallowed from being registered.");

Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/ApiV3/ResourceApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ public ApiResponse<ApiGameAssetResponse> GetPspAssetInfo(RequestContext context,
string hash, DataContext dataContext) => this.GetAssetInfo(context, database, dataStore, $"psp/{hash}", dataContext);

[ApiV3Endpoint("assets/{hash}", HttpMethods.Post)]
[RequireEmailVerified]
[DocSummary("Uploads an image (PNG/JPEG) asset")]
[DocError(typeof(ApiValidationError), ApiValidationError.HashMissingErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.BodyTooLongErrorWhen)]
Expand Down
9 changes: 7 additions & 2 deletions Refresh.GameServer/Endpoints/ApiV3/UserApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
using Bunkum.Core.Endpoints;
using Bunkum.Core.Storage;
using Bunkum.Protocols.Http;
using Refresh.GameServer.Configuration;
using Refresh.GameServer.Database;
using Refresh.GameServer.Endpoints.ApiV3.ApiTypes;
using Refresh.GameServer.Endpoints.ApiV3.ApiTypes.Errors;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Request;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response.Users;
using Refresh.GameServer.Services;
using Refresh.GameServer.Types.Data;
using Refresh.GameServer.Types.Roles;
using Refresh.GameServer.Types.UserData;
Expand Down Expand Up @@ -53,11 +54,15 @@ public ApiResponse<ApiExtendedGameUserResponse> GetMyUser(RequestContext context
[ApiV3Endpoint("users/me", HttpMethods.Patch)]
[DocSummary("Updates your profile with the given data")]
public ApiResponse<ApiExtendedGameUserResponse> UpdateUser(RequestContext context, GameDatabaseContext database,
GameUser user, ApiUpdateUserRequest body, IDataStore dataStore, DataContext dataContext)
GameUser user, ApiUpdateUserRequest body, IDataStore dataStore, DataContext dataContext, IntegrationConfig integrationConfig,
SmtpService smtpService)
{
if (body.IconHash != null && database.GetAssetFromHash(body.IconHash) == null)
return ApiNotFoundError.Instance;

if (body.EmailAddress != null && !smtpService.CheckEmailDomainValidity(body.EmailAddress))
return ApiValidationError.EmailDoesNotActuallyExistError;

database.UpdateUserData(user, body);
return ApiExtendedGameUserResponse.FromOld(user, dataContext);
}
Expand Down
12 changes: 12 additions & 0 deletions Refresh.GameServer/Endpoints/Game/AnnouncementEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ Your account is currently in restricted mode.
For more information, please contact an administrator.
""";
}

if (!user.EmailAddressVerified)
{
return $"Your account doesn't have a verified email address. If this is a new account, there should be a verification code in your inbox.\n\n" +

$"You can still play online without a verified email address if you wish, " +
$"but you might miss out on 'share' features like level uploading, and you will not be able to dive in.\n\n" +

$"If you didn't receive it, try checking your spam folder. You can also opt to resend the code on the website.\n\n" +

$"For more information, sign into the site at {config.WebExternalUrl}.";
}

// ReSharper disable once JoinDeclarationAndInitializer (makes it easier to follow)
bool appended;
Expand Down
2 changes: 2 additions & 0 deletions Refresh.GameServer/Endpoints/Game/CommentEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace Refresh.GameServer.Endpoints.Game;
public class CommentEndpoints : EndpointGroup
{
[GameEndpoint("postUserComment/{username}", ContentType.Xml, HttpMethods.Post)]
[RequireEmailVerified]
public Response PostProfileComment(RequestContext context, GameDatabaseContext database, string username, SerializedComment body, GameUser user, IDateTimeProvider timeProvider)
{
if (body.Content.Length > 4096)
Expand Down Expand Up @@ -70,6 +71,7 @@ public Response DeleteProfileComment(RequestContext context, GameDatabaseContext
}

[GameEndpoint("postComment/{slotType}/{id}", ContentType.Xml, HttpMethods.Post)]
[RequireEmailVerified]
public Response PostLevelComment(RequestContext context, GameDatabaseContext database, string slotType, int id, SerializedComment body, GameUser user)
{
if (body.Content.Length > 4096)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class LeaderboardEndpoints : EndpointGroup
private const int MaxRequestAmount = 250;
private const int RequestBlockDuration = 300;
private const string BucketName = "score";

[GameEndpoint("play/{slotType}/{id}", ContentType.Xml, HttpMethods.Post)]
public Response PlayLevel(RequestContext context, GameUser user, GameDatabaseContext database, string slotType, int id)
{
Expand Down Expand Up @@ -69,6 +69,7 @@ public Response GetUserScores(RequestContext context, GameUser user, GameDatabas

[GameEndpoint("scoreboard/{slotType}/{id}", ContentType.Xml, HttpMethods.Post)]
[RateLimitSettings(RequestTimeoutDuration, MaxRequestAmount, RequestBlockDuration, BucketName)]
[RequireEmailVerified]
public Response SubmitScore(RequestContext context, GameUser user, GameDatabaseContext database, string slotType, int id, SerializedScore body, Token token)
{
GameLevel? level = database.GetLevelByIdAndType(slotType, id);
Expand Down
2 changes: 2 additions & 0 deletions Refresh.GameServer/Endpoints/Game/Levels/PublishEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ private static bool VerifyLevel(GameLevelRequest body, DataContext dataContext)

[GameEndpoint("startPublish", ContentType.Xml, HttpMethods.Post)]
[NullStatusCode(BadRequest)]
[RequireEmailVerified]
public SerializedLevelResources? StartPublish(RequestContext context,
GameLevelRequest body,
CommandService command,
Expand Down Expand Up @@ -86,6 +87,7 @@ private static bool VerifyLevel(GameLevelRequest body, DataContext dataContext)
}

[GameEndpoint("publish", ContentType.Xml, HttpMethods.Post)]
[RequireEmailVerified]
public Response PublishLevel(RequestContext context,
GameLevelRequest body,
CommandService commandService,
Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/Game/MatchingEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class MatchingEndpoints : EndpointGroup
// [FindBestRoom,["Players":["VitaGamer128"],"Reservations":["0"],"NAT":[2],"Slots":[[5,0]],"Location":[0x17257bc9,0x17257bf2],"Language":1,"BuildVersion":289,"Search":"","RoomState":3]]
[GameEndpoint("match", HttpMethods.Post, ContentType.Json)]
[DebugRequestBody, DebugResponseBody]
[RequireEmailVerified]
public Response Match(
RequestContext context,
string body,
Expand Down
2 changes: 2 additions & 0 deletions Refresh.GameServer/Endpoints/Game/ModerationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public SerializedModeratedResourceList ModerateResources(RequestContext context,
/// <param name="database">The database. Used for commands</param>
/// <returns>The string shown in-game.</returns>
[GameEndpoint("filter", HttpMethods.Post)]
[RequireEmailVerified]
[AllowEmptyBody]
public string Filter(RequestContext context, CommandService commandService, string body, GameUser user, Token token, GameDatabaseContext database)
{
Expand Down Expand Up @@ -90,6 +91,7 @@ public string Filter(RequestContext context, CommandService commandService, stri
}

[GameEndpoint("filter/batch", HttpMethods.Post, ContentType.Xml)]
[RequireEmailVerified]
public SerializedTextList BatchFilter(RequestContext context, SerializedTextList body)
{
return new SerializedTextList
Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/Game/PhotoEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace Refresh.GameServer.Endpoints.Game;
public class PhotoEndpoints : EndpointGroup
{
[GameEndpoint("uploadPhoto", HttpMethods.Post, ContentType.Xml)]
[RequireEmailVerified]
public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDatabaseContext database, GameUser user, IDataStore dataStore)
{
if (!dataStore.ExistsInStore(body.SmallHash) ||
Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace Refresh.GameServer.Endpoints.Game;
public class ReportingEndpoints : EndpointGroup
{
[GameEndpoint("grief", HttpMethods.Post, ContentType.Xml)]
[RequireEmailVerified]
public Response UploadReport(RequestContext context, GameDatabaseContext database, GameReport body, GameUser user, IDateTimeProvider time, Token token)
{
GameLevel? level = database.GetLevelById(body.LevelId);
Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/Game/ResourceEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class ResourceEndpoints : EndpointGroup
//NOTE: type does nothing here, but it's sent by LBP so we have to accept it
[GameEndpoint("upload/{hash}/{type}", HttpMethods.Post)]
[GameEndpoint("upload/{hash}", HttpMethods.Post)]
[RequireEmailVerified]
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
public Response UploadAsset(RequestContext context, string hash, string type, byte[] body, IDataStore dataStore,
GameDatabaseContext database, GameUser user, AssetImporter importer, GameServerConfig config, IDateTimeProvider timeProvider, Token token)
Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/Game/ReviewEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public Response GetReviewsForLevel(RequestContext context, GameDatabaseContext d
}

[GameEndpoint("postReview/{slotType}/{id}", ContentType.Xml, HttpMethods.Post)]
[RequireEmailVerified]
public Response PostReviewForLevel(
RequestContext context,
GameDatabaseContext database,
Expand Down
4 changes: 4 additions & 0 deletions Refresh.GameServer/Endpoints/RequireEmailVerifiedAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Refresh.GameServer.Endpoints;

[AttributeUsage(AttributeTargets.Method)]
public class RequireEmailVerifiedAttribute : Attribute;
1 change: 1 addition & 0 deletions Refresh.GameServer/Refresh.GameServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Discord.Net.Webhook" Version="3.15.3" />
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="FastAes" Version="1.0.1" />
<PackageReference Include="IronCompress" Version="1.5.2" />
<PackageReference Include="MailKit" Version="4.7.1.1" />
Expand Down
4 changes: 4 additions & 0 deletions Refresh.GameServer/Services/RoleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ internal RoleService(AuthenticationService authService, GameServerConfig config,
if (user.Role < minimumRole)
return Unauthorized;

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

return null;
}

Expand Down
16 changes: 16 additions & 0 deletions Refresh.GameServer/Services/SmtpService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Bunkum.Core;
using Bunkum.Core.Services;
using DnsClient;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
Expand Down Expand Up @@ -102,4 +103,19 @@ public bool SendPasswordResetRequest(GameUser user, string token)
The {this._gameConfig.InstanceName} Team
""");
}

public bool CheckEmailDomainValidity(string emailAddress)
{
if (this._integrationConfig is not { SmtpVerifyDomain: true, SmtpEnabled: true })
return true;

string domain = emailAddress[(emailAddress.IndexOf('@') + 1)..];
this.Logger.LogDebug(BunkumCategory.Authentication, $"Checking validity of email {emailAddress} (domain: {domain})");

LookupClient dns = new();
IDnsQueryResponse? records = dns.Query(domain, QueryType.MX);
if (records == null) return false;

return records.Answers.MxRecords().Any();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ protected override void SetupServices()
this.Server.AddService<LevelListOverrideService>();
this.Server.AddService<CommandService>();
this.Server.AddService<GuidCheckerService>();
this.Server.AddService<SmtpService>();

// Must always be last, see comment in RefreshGameServer
this.Server.AddService<DataContextService>();
Expand Down

0 comments on commit 4638747

Please sign in to comment.