diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index cc787758..fd71dbea 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "7.0.0", + "version": "7.0.1", "commands": [ "dotnet-ef" ] diff --git a/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs b/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs index 476fed06..4ed6439a 100644 --- a/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs +++ b/ProjectLighthouse.Servers.API/Controllers/UserEndpoints.cs @@ -71,8 +71,7 @@ public class UserEndpoints : ApiEndpointController [HttpPost("user/inviteToken/{username}")] public async Task CreateUserInviteToken([FromRoute] string? username) { - if (!Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration && - !Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled) + if (!Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled) return this.NotFound(); string? authHeader = this.Request.Headers["Authorization"]; diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs index 11ae7662..1fbb4dbe 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/LoginController.cs @@ -1,6 +1,7 @@ #nullable enable using System.Net; using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Match.Rooms; @@ -51,87 +52,138 @@ public class LoginController : ControllerBase if (remoteIpAddress == null) { Logger.Warn("unable to determine ip, rejecting login", LogArea.Login); - return this.StatusCode(403, ""); // 403 probably isnt the best status code for this, but whatever + return this.BadRequest(); } string ipAddress = remoteIpAddress.ToString(); + string? username = npTicket.Username; + + if (username == null) + { + Logger.Warn("Unable to determine username, rejecting login", LogArea.Login); + return this.StatusCode(403, ""); + } + await this.database.RemoveExpiredTokens(); - // Get an existing token from the IP & username - GameToken? token = await this.database.GameTokens.Include(t => t.User) - .FirstOrDefaultAsync(t => t.UserLocation == ipAddress && t.User.Username == npTicket.Username && !t.Used); + User? user; - if (token == null) // If we cant find an existing token, try to generate a new one + switch (npTicket.Platform) { - token = await this.database.AuthenticateUser(npTicket, ipAddress); - if (token == null) + case Platform.RPCS3: + user = await this.database.Users.FirstOrDefaultAsync(u => u.LinkedRpcnId == npTicket.UserId); + break; + case Platform.PS3: + case Platform.Vita: + case Platform.UnitTest: + user = await this.database.Users.FirstOrDefaultAsync(u => u.LinkedPsnId == npTicket.UserId); + break; + case Platform.PSP: + case Platform.Unknown: + default: + throw new ArgumentOutOfRangeException(); + } + + // If this user id hasn't been linked to any accounts + if (user == null) + { + // Check if there is an account with that username already + User? targetUsername = await this.database.Users.FirstOrDefaultAsync(u => u.Username == npTicket.Username); + if (targetUsername != null) { - Logger.Warn($"Unable to find/generate a token for username {npTicket.Username}", LogArea.Login); - return this.StatusCode(403, ""); // If not, then 403. - } - } + ulong targetPlatform = npTicket.Platform == Platform.RPCS3 + ? targetUsername.LinkedRpcnId + : targetUsername.LinkedPsnId; - // The GameToken LINQ statement above is case insensitive so we check that they are equal here - if (token.User.Username != npTicket.Username) - { - Logger.Warn($"Username case does not match for user {npTicket.Username}, expected={token.User.Username}", LogArea.Login); - return this.StatusCode(403, ""); - } - - User? user = await this.database.UserFromGameToken(token); - - if (user == null || user.IsBanned) - { - Logger.Error($"Unable to find user {npTicket.Username} from token", LogArea.Login); - return this.StatusCode(403, ""); - } - - if (ServerConfiguration.Instance.Mail.MailEnabled && (user.EmailAddress == null || !user.EmailAddressVerified)) - { - Logger.Error($"Email address unverified for user {user.Username}", LogArea.Login); - return this.StatusCode(403, ""); - } - - if (ServerConfiguration.Instance.Authentication.UseExternalAuth) - { - if (user.ApprovedIPAddress == ipAddress) - { - token.Approved = true; - } - else - { - AuthenticationAttempt authAttempt = new() + // only make a link request if the user doesn't already have an account linked for that platform + if (targetPlatform != 0) + { + Logger.Warn($"New user tried to login but their name is already taken, username={username}", LogArea.Login); + return this.StatusCode(403, ""); + } + + // if there is already a pending link request don't create another + bool linkAttemptExists = await this.database.PlatformLinkAttempts.AnyAsync(p => + p.Platform == npTicket.Platform && + p.PlatformId == npTicket.UserId && + p.UserId == targetUsername.UserId); + + if (linkAttemptExists) return this.StatusCode(403, ""); + + PlatformLinkAttempt linkAttempt = new() { - GameToken = token, - GameTokenId = token.TokenId, - Timestamp = TimeHelper.Timestamp, - IPAddress = ipAddress, Platform = npTicket.Platform, + UserId = targetUsername.UserId, + IPAddress = ipAddress, + Timestamp = TimeHelper.TimestampMillis, + PlatformId = npTicket.UserId, }; - - this.database.AuthenticationAttempts.Add(authAttempt); + this.database.PlatformLinkAttempts.Add(linkAttempt); + await this.database.SaveChangesAsync(); + Logger.Success($"User '{npTicket.Username}' tried to login but platform isn't linked, platform={npTicket.Platform}", LogArea.Login); + return this.StatusCode(403, ""); } + + if (!ServerConfiguration.Instance.Authentication.AutomaticAccountCreation) + { + Logger.Warn($"Unknown user tried to connect username={username}", LogArea.Login); + return this.StatusCode(403, ""); + } + // create account for user if they don't exist + user = await this.database.CreateUser(username, "$"); + user.Password = null; + user.LinkedRpcnId = npTicket.Platform == Platform.RPCS3 ? npTicket.UserId : 0; + user.LinkedPsnId = npTicket.Platform != Platform.RPCS3 ? npTicket.UserId : 0; + await this.database.SaveChangesAsync(); + + Logger.Success($"Created new user for {username}, platform={npTicket.Platform}", LogArea.Login); } - else + // automatically change username if it doesn't match + else if (user.Username != npTicket.Username) { - token.Approved = true; + bool usernameExists = await this.database.Users.AnyAsync(u => u.Username == npTicket.Username); + if (usernameExists) + { + Logger.Warn($"{npTicket.Platform} user changed their name to a name that is already taken," + + $" oldName='{user.Username}', newName='{npTicket.Username}'", LogArea.Login); + return this.StatusCode(403, ""); + } + Logger.Info($"User's username has changed, old='{user.Username}', new='{npTicket.Username}', platform={npTicket.Platform}", LogArea.Login); + user.Username = username; + this.database.PlatformLinkAttempts.RemoveWhere(p => p.UserId == user.UserId); + // unlink other platforms because the names no longer match + if (npTicket.Platform == Platform.RPCS3) + user.LinkedPsnId = 0; + else + user.LinkedRpcnId = 0; + + await this.database.SaveChangesAsync(); } - await this.database.SaveChangesAsync(); + GameToken? token = await this.database.GameTokens.Include(t => t.User) + .FirstOrDefaultAsync(t => t.UserLocation == ipAddress && t.User.Username == npTicket.Username && t.TicketHash == npTicket.TicketHash); - if (!token.Approved) + if (token != null) { - Logger.Warn($"Token unapproved for user {user.Username}, rejecting login", LogArea.Login); + Logger.Warn($"Rejecting duplicate ticket from {username}", LogArea.Login); + return this.StatusCode(403, ""); + } + + token = await this.database.AuthenticateUser(user, npTicket, ipAddress); + if (token == null) + { + Logger.Warn($"Unable to find/generate a token for username {npTicket.Username}", LogArea.Login); + return this.StatusCode(403, ""); + } + + if (user.IsBanned) + { + Logger.Error($"User {npTicket.Username} tried to login but is banned", LogArea.Login); return this.StatusCode(403, ""); } Logger.Success($"Successfully logged in user {user.Username} as {token.GameVersion} client", LogArea.Login); - // After this point we are now considering this session as logged in. - - // We just logged in with the token. Mark it as used so someone else doesnt try to use it, - // and so we don't pick the same token up when logging in later. - token.Used = true; user.LastLogin = TimeHelper.TimestampMillis; diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs index 4d9ccc3a..03d7f753 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/MessageController.cs @@ -1,12 +1,13 @@ #nullable enable -using System.Globalization; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.PlayerData; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers; @@ -58,11 +59,10 @@ along with this program. If not, see ."; #if DEBUG "\n\n---DEBUG INFO---\n" + $"user.UserId: {token.UserId}\n" + - $"token.Approved: {token.Approved}\n" + - $"token.Used: {token.Used}\n" + $"token.UserLocation: {token.UserLocation}\n" + $"token.GameVersion: {token.GameVersion}\n" + - $"token.ExpiresAt: {token.ExpiresAt.ToString(CultureInfo.CurrentCulture)}\n" + + $"token.TicketHash: {token.TicketHash}\n" + + $"token.ExpiresAt: {token.ExpiresAt.ToString()}\n" + "---DEBUG INFO---" + #endif (string.IsNullOrWhiteSpace(announceText) ? "" : "\n") @@ -81,13 +81,46 @@ along with this program. If not, see ."; { GameToken token = this.GetToken(); - string response = await new StreamReader(this.Request.Body).ReadToEndAsync(); + string message = await new StreamReader(this.Request.Body).ReadToEndAsync(); - string scannedText = CensorHelper.ScanMessage(response); + if (message.StartsWith("/setemail ")) + { + string email = message[(message.IndexOf(" ", StringComparison.Ordinal)+1)..]; + if (!SanitizationHelper.IsValidEmail(email)) return this.Ok(); + + if (await this.database.Users.AnyAsync(u => u.EmailAddress == email)) return this.Ok(); + + User? user = await this.database.UserFromGameToken(token); + if (user == null || user.EmailAddress != null) return this.Ok(); + + PasswordResetToken resetToken = new() + { + Created = DateTime.Now, + UserId = user.UserId, + ResetToken = CryptoHelper.GenerateAuthToken(), + }; + + string messageBody = $"Hello, {user.Username}.\n\n" + + "A request to set your account's password was issued. If this wasn't you, this can probably be ignored.\n\n" + + $"If this was you, your {ServerConfiguration.Instance.Customization.ServerName} password can be set at the following link:\n" + + $"{ServerConfiguration.Instance.ExternalUrl}/passwordReset?token={resetToken.ResetToken}"; + + SMTPHelper.SendEmail(user.EmailAddress, $"Project Lighthouse Password Setup Request for {user.Username}", messageBody); + + this.database.PasswordResetTokens.Add(resetToken); + + user.EmailAddress = email; + user.EmailAddressVerified = true; + await this.database.SaveChangesAsync(); + + return this.Ok(); + } + + string scannedText = CensorHelper.ScanMessage(message); string username = await this.database.UsernameFromGameToken(token); - Logger.Info($"{username}: {response} / {scannedText}", LogArea.Filter); + Logger.Info($"{username}: {message} / {scannedText}", LogArea.Filter); return this.Ok(scannedText); } diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs index e3437ff5..3452d0c7 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Resources/PhotosController.cs @@ -188,20 +188,14 @@ public class PhotosController : ControllerBase List photoSubjectIds = new(); photoSubjectIds.AddRange(this.database.PhotoSubjects.Where(p => p.UserId == targetUserId).Select(p => p.PhotoSubjectId)); + List photos = (from id in photoSubjectIds from p in + this.database.Photos.Include(p => p.Creator).Where(p => p.PhotoSubjectCollection.Contains(id.ToString())) + where p.PhotoSubjectCollection.Split(",").Contains(id.ToString()) && p.CreatorId != targetUserId select p).ToList(); - var list = this.database.Photos.Select(p => new - { - p.PhotoId, - p.PhotoSubjectCollection, - }).ToList(); - List photoIds = (from v in list where photoSubjectIds.Any(ps => v.PhotoSubjectCollection.Split(",").Contains(ps.ToString())) select v.PhotoId).ToList(); - - string response = Enumerable.Aggregate( - this.database.Photos.Where(p => photoIds.Any(id => p.PhotoId == id) && p.CreatorId != targetUserId) - .OrderByDescending(s => s.Timestamp) - .Skip(Math.Max(0, pageStart - 1)) - .Take(Math.Min(pageSize, 30)), - string.Empty, + string response = photos + .OrderByDescending(s => s.Timestamp) + .Skip(Math.Max(0, pageStart - 1)) + .Take(Math.Min(pageSize, 30)).Aggregate(string.Empty, (current, photo) => current + photo.Serialize()); return this.Ok(LbpSerializer.StringElement("photos", response)); diff --git a/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AuthenticationController.cs b/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AuthenticationController.cs index 8ae4e279..57cf9909 100644 --- a/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AuthenticationController.cs +++ b/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AuthenticationController.cs @@ -1,7 +1,7 @@ #nullable enable +using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.PlayerData.Profiles; -using LBPUnion.ProjectLighthouse.Types; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,21 +18,54 @@ public class AuthenticationController : ControllerBase this.database = database; } + [HttpGet("unlink/{platform}")] + public async Task UnlinkPlatform(string platform) + { + User? user = this.database.UserFromWebRequest(this.Request); + if (user == null) return this.Redirect("~/login"); + + Platform[] invalidTokens; + + if (platform == "psn") + { + user.LinkedPsnId = 0; + invalidTokens = new[] { Platform.PS3, Platform.Vita, }; + } + else + { + user.LinkedRpcnId = 0; + invalidTokens = new[] { Platform.RPCS3, }; + } + + this.database.GameTokens.RemoveWhere(t => t.UserId == user.UserId && invalidTokens.Contains(t.Platform)); + + await this.database.SaveChangesAsync(); + + return this.Redirect("~/authentication"); + } + [HttpGet("approve/{id:int}")] public async Task Approve(int id) { User? user = this.database.UserFromWebRequest(this.Request); if (user == null) return this.Redirect("/login"); - AuthenticationAttempt? authAttempt = await this.database.AuthenticationAttempts.Include - (a => a.GameToken) - .FirstOrDefaultAsync(a => a.AuthenticationAttemptId == id); - if (authAttempt == null) return this.NotFound(); + PlatformLinkAttempt? linkAttempt = await this.database.PlatformLinkAttempts + .FirstOrDefaultAsync(l => l.PlatformLinkAttemptId == id); + if (linkAttempt == null) return this.NotFound(); - if (authAttempt.GameToken.UserId != user.UserId) return this.StatusCode(403, ""); + if (linkAttempt.UserId != user.UserId) return this.NotFound(); - authAttempt.GameToken.Approved = true; - this.database.AuthenticationAttempts.Remove(authAttempt); + if (linkAttempt.Platform == Platform.RPCS3) + { + user.LinkedRpcnId = linkAttempt.PlatformId; + } + else + { + user.LinkedPsnId = linkAttempt.PlatformId; + } + + this.database.PlatformLinkAttempts.Remove(linkAttempt); await this.database.SaveChangesAsync(); @@ -45,37 +78,13 @@ public class AuthenticationController : ControllerBase User? user = this.database.UserFromWebRequest(this.Request); if (user == null) return this.Redirect("/login"); - AuthenticationAttempt? authAttempt = await this.database.AuthenticationAttempts.Include - (a => a.GameToken) - .FirstOrDefaultAsync(a => a.AuthenticationAttemptId == id); - if (authAttempt == null) return this.NotFound(); + PlatformLinkAttempt? linkAttempt = await this.database.PlatformLinkAttempts + .FirstOrDefaultAsync(l => l.PlatformLinkAttemptId == id); + if (linkAttempt == null) return this.NotFound(); - if (authAttempt.GameToken.UserId != user.UserId) return this.StatusCode(403, ""); + if (linkAttempt.UserId != user.UserId) return this.NotFound(); - this.database.GameTokens.Remove(authAttempt.GameToken); - this.database.AuthenticationAttempts.Remove(authAttempt); - - await this.database.SaveChangesAsync(); - - return this.Redirect("~/authentication"); - } - - [HttpGet("denyAll")] - public async Task DenyAll() - { - User? user = this.database.UserFromWebRequest(this.Request); - if (user == null) return this.Redirect("/login"); - - List authAttempts = await this.database.AuthenticationAttempts.Include - (a => a.GameToken) - .Where(a => a.GameToken.UserId == user.UserId) - .ToListAsync(); - - foreach (AuthenticationAttempt authAttempt in authAttempts) - { - this.database.GameTokens.Remove(authAttempt.GameToken); - this.database.AuthenticationAttempts.Remove(authAttempt); - } + this.database.PlatformLinkAttempts.Remove(linkAttempt); await this.database.SaveChangesAsync(); diff --git a/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AutoApprovalController.cs b/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AutoApprovalController.cs deleted file mode 100644 index b5cd66d7..00000000 --- a/ProjectLighthouse.Servers.Website/Controllers/ExternalAuth/AutoApprovalController.cs +++ /dev/null @@ -1,56 +0,0 @@ -#nullable enable -using LBPUnion.ProjectLighthouse.PlayerData; -using LBPUnion.ProjectLighthouse.PlayerData.Profiles; -using LBPUnion.ProjectLighthouse.Types; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace LBPUnion.ProjectLighthouse.Servers.Website.Controllers.ExternalAuth; - -[ApiController] -[Route("/authentication")] -public class AutoApprovalController : ControllerBase -{ - private readonly Database database; - - public AutoApprovalController(Database database) - { - this.database = database; - } - - [HttpGet("autoApprove/{id:int}")] - public async Task AutoApprove([FromRoute] int id) - { - User? user = this.database.UserFromWebRequest(this.Request); - if (user == null) return this.Redirect("/login"); - - AuthenticationAttempt? authAttempt = await this.database.AuthenticationAttempts.Include - (a => a.GameToken) - .FirstOrDefaultAsync(a => a.AuthenticationAttemptId == id); - - if (authAttempt == null) return this.BadRequest(); - if (authAttempt.GameToken.UserId != user.UserId) return this.Redirect("/login"); - - authAttempt.GameToken.Approved = true; - user.ApprovedIPAddress = authAttempt.IPAddress; - - this.database.AuthenticationAttempts.Remove(authAttempt); - - await this.database.SaveChangesAsync(); - - return this.Redirect("/authentication"); - } - - [HttpGet("revokeAutoApproval")] - public async Task RevokeAutoApproval() - { - User? user = this.database.UserFromWebRequest(this.Request); - if (user == null) return this.Redirect("/login"); - - user.ApprovedIPAddress = null; - - await this.database.SaveChangesAsync(); - - return this.Redirect("/authentication"); - } -} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml index ab33c246..db310012 100644 --- a/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml @@ -4,62 +4,38 @@ @{ Layout = "Layouts/BaseLayout"; - Model.Title = "Authentication"; + Model.Title = "Linked Accounts"; string timeZone = Model.GetTimeZone(); TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone); } -@if (Model.AuthenticationAttempts.Count == 0) +@if (Model.LinkAttempts.Count == 0) { -

You have no pending authentication attempts.

+

You have no pending link attempts.

} else { -

You have @Model.AuthenticationAttempts.Count authentication attempts pending.

+

You have @Model.LinkAttempts.Count authentication attempts pending.

@if (Model.IpAddress != null) { -

This device's IP address is @(Model.IpAddress.ToString()). If this matches with an authentication attempt below, then it's safe to assume the authentication attempt came from the same network as this device.

+

This device's IP address is @(Model.IpAddress.ToString()). If this matches with a link attempt below, then it's safe to assume the link attempt came from the same network as this device.

} } -@if (Model.User!.ApprovedIPAddress != null) +@foreach (PlatformLinkAttempt authAttempt in Model.LinkAttempts) { - - - -} -@if (Model.AuthenticationAttempts.Count > 1) -{ - - - -} - -@foreach (AuthenticationAttempt authAttempt in Model.AuthenticationAttempts) -{ - DateTimeOffset timestamp = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(authAttempt.Timestamp), timeZoneInfo); + DateTimeOffset timestamp = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeMilliseconds(authAttempt.Timestamp), timeZoneInfo);
-

A @authAttempt.Platform authentication request was logged at @timestamp.ToString("M/d/yyyy @ h:mm tt") from the IP address @authAttempt.IPAddress.

+

A @authAttempt.Platform link request was logged at @timestamp.ToString("M/d/yyyy @ h:mm tt") from the IP address @authAttempt.IPAddress.

+

If you approve this request it will override any other linked accounts you have

-} \ No newline at end of file +} + +
+

PSN:

+ @if (Model.User?.LinkedPsnId != 0) + { +
+ Linked +
+ + Click here to unlink this platform + + } + else + { + + } +
+
+
+

RPCN:

+ @if (Model.User?.LinkedRpcnId != 0) + { +
+ Linked +
+ + Click here to unlink this platform + + } + else + { + + } +
\ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml.cs index 6e8cdc7c..8ee93a4c 100644 --- a/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/ExternalAuth/AuthenticationPage.cshtml.cs @@ -1,18 +1,15 @@ #nullable enable using System.Net; -using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts; -using LBPUnion.ProjectLighthouse.Types; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.ExternalAuth; public class AuthenticationPage : BaseLayout { - public List AuthenticationAttempts = new(); + public List LinkAttempts = new(); public IPAddress? IpAddress; public AuthenticationPage(Database database) : base(database) @@ -20,16 +17,14 @@ public class AuthenticationPage : BaseLayout public IActionResult OnGet() { - if (!ServerConfiguration.Instance.Authentication.UseExternalAuth) return this.NotFound(); if (this.User == null) return this.StatusCode(403, ""); this.IpAddress = this.HttpContext.Connection.RemoteIpAddress; - this.AuthenticationAttempts = this.Database.AuthenticationAttempts.Include - (a => a.GameToken) - .Where(a => a.GameToken.UserId == this.User.UserId) - .OrderByDescending(a => a.Timestamp) - .ToList(); + this.LinkAttempts = this.Database.PlatformLinkAttempts + .Where(l => l.UserId == this.User.UserId) + .OrderByDescending(a => a.Timestamp) + .ToList(); return this.Page(); } diff --git a/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml b/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml index 02468066..00ec7d9a 100644 --- a/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml @@ -22,7 +22,7 @@ @if (Model.User != null) {

@Model.Translate(LandingPageStrings.LoggedInAs, Model.User.Username)

- if (ServerConfiguration.Instance.Authentication.UseExternalAuth && Model.PendingAuthAttempts > 0) + if (Model.PendingAuthAttempts > 0) {

@Model.Translate(LandingPageStrings.AuthAttemptsPending, Model.PendingAuthAttempts) @@ -30,18 +30,18 @@ } } -@if (Model.PlayersOnline.Count == 1) -{ -

@Model.Translate(LandingPageStrings.UsersSingle)

-} -else if (Model.PlayersOnline.Count == 0) +@switch (Model.PlayersOnline.Count) { -

@Model.Translate(LandingPageStrings.UsersNone)

-} -else -{ -

@Model.Translate(LandingPageStrings.UsersMultiple, Model.PlayersOnline.Count)

+ case 0: +

@Model.Translate(LandingPageStrings.UsersNone)

+ break; + case 1: +

@Model.Translate(LandingPageStrings.UsersSingle)

+ break; + default: +

@Model.Translate(LandingPageStrings.UsersMultiple, Model.PlayersOnline.Count)

+ break; } @{ @@ -49,7 +49,12 @@ else foreach (User user in Model.PlayersOnline) { i++; - @await user.ToLink(Html, ViewData, language, timeZone, true)if (i != Model.PlayersOnline.Count){,} @* whitespace has forced my hand *@ + @await user.ToLink(Html, ViewData, language, timeZone, true) + @* whitespace has forced my hand *@ + if (i != Model.PlayersOnline.Count) + { + , + } } } diff --git a/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml.cs index 008d8fcc..9f13cf73 100644 --- a/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/LandingPage.cshtml.cs @@ -27,9 +27,8 @@ public class LandingPage : BaseLayout if (user != null && user.PasswordResetRequired) return this.Redirect("~/passwordResetRequired"); if (user != null) - this.PendingAuthAttempts = await this.Database.AuthenticationAttempts.Include - (a => a.GameToken) - .CountAsync(a => a.GameToken.UserId == user.UserId); + this.PendingAuthAttempts = await this.Database.PlatformLinkAttempts + .CountAsync(l => l.UserId == user.UserId); List userIds = await this.Database.LastContacts.Where(l => TimeHelper.Timestamp - l.Timestamp < 300).Select(l => l.UserId).ToListAsync(); diff --git a/ProjectLighthouse.Servers.Website/Pages/Layouts/BaseLayout.cshtml b/ProjectLighthouse.Servers.Website/Pages/Layouts/BaseLayout.cshtml index 07126167..5433133c 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Layouts/BaseLayout.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/Layouts/BaseLayout.cshtml @@ -12,10 +12,7 @@ } else { - if (ServerConfiguration.Instance.Authentication.UseExternalAuth) - { - Model.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAuthentication, "/authentication", "key")); - } + Model.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAuthentication, "/authentication", "key")); @if (Model.User.IsAdmin) { diff --git a/ProjectLighthouse.Servers.Website/Pages/Login/LoginForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Login/LoginForm.cshtml.cs index c9852c60..92528a19 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Login/LoginForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Login/LoginForm.cshtml.cs @@ -60,7 +60,7 @@ public class LoginForm : BaseLayout } } - if (user == null) + if (user == null || user.Password == null) { Logger.Warn($"User {username} failed to login on web due to invalid username", LogArea.Login); this.Error = ServerConfiguration.Instance.Mail.MailEnabled diff --git a/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs index f1bbd986..30b7a2db 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Login/PasswordResetRequestForm.cshtml.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Helpers; @@ -36,7 +35,7 @@ public class PasswordResetRequestForm : BaseLayout return this.Page(); } - if (!new EmailAddressAttribute().IsValid(email)) + if (!SanitizationHelper.IsValidEmail(email)) { this.Error = "This email is in an invalid format"; return this.Page(); diff --git a/ProjectLighthouse.Servers.Website/Pages/Login/RegisterForm.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/Login/RegisterForm.cshtml.cs index 6d3637ca..3538df1b 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Login/RegisterForm.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/Login/RegisterForm.cshtml.cs @@ -25,30 +25,9 @@ public class RegisterForm : BaseLayout [SuppressMessage("ReSharper", "SpecifyStringComparison")] public async Task OnPost(string username, string password, string confirmPassword, string emailAddress) { - if (ServerConfiguration.Instance.Authentication.PrivateRegistration) - { - if (this.Request.Query.ContainsKey("token")) - { - string? token = this.Request.Query["token"]; - if (!this.Database.IsRegistrationTokenValid(token)) - return this.StatusCode(403, this.Translate(ErrorStrings.TokenInvalid)); + if (this.Database.UserFromWebRequest(this.Request) != null) return this.Redirect("~/"); - string? tokenUsername = await this.Database.RegistrationTokens.Where(r => r.Token == token) - .Select(u => u.Username) - .FirstOrDefaultAsync(); - if (tokenUsername == null) return this.BadRequest(); - - username = tokenUsername; - } - else - { - return this.NotFound(); - } - } - else if (!ServerConfiguration.Instance.Authentication.RegistrationEnabled) - { - return this.NotFound(); - } + if (!ServerConfiguration.Instance.Authentication.RegistrationEnabled) return this.NotFound(); if (string.IsNullOrWhiteSpace(username)) { @@ -74,7 +53,8 @@ public class RegisterForm : BaseLayout return this.Page(); } - if (await this.Database.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower()) != null) + User? existingUser = await this.Database.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower()); + if (existingUser != null) { this.Error = this.Translate(ErrorStrings.UsernameTaken); return this.Page(); @@ -93,11 +73,6 @@ public class RegisterForm : BaseLayout return this.Page(); } - if (this.Request.Query.ContainsKey("token")) - { - await this.Database.RemoveRegistrationToken(this.Request.Query["token"]); - } - User user = await this.Database.CreateUser(username, CryptoHelper.BCryptHash(password), emailAddress); WebToken webToken = new() @@ -112,35 +87,17 @@ public class RegisterForm : BaseLayout this.Response.Cookies.Append("LighthouseToken", webToken.UserToken); - if (ServerConfiguration.Instance.Mail.MailEnabled) return this.Redirect("~/login/sendVerificationEmail"); - - return this.Redirect("~/"); + return ServerConfiguration.Instance.Mail.MailEnabled ? + this.Redirect("~/login/sendVerificationEmail") : + this.Redirect("~/"); } [UsedImplicitly] [SuppressMessage("ReSharper", "SpecifyStringComparison")] - public async Task OnGet() + public IActionResult OnGet() { this.Error = string.Empty; - if (ServerConfiguration.Instance.Authentication.PrivateRegistration) - { - if (this.Request.Query.ContainsKey("token")) - { - string? token = this.Request.Query["token"]; - if (!this.Database.IsRegistrationTokenValid(token)) - return this.StatusCode(403, this.Translate(ErrorStrings.TokenInvalid)); - - string? tokenUsername = await this.Database.RegistrationTokens.Where(r => r.Token == token) - .Select(u => u.Username) - .FirstAsync(); - this.Username = tokenUsername; - } - else - { - return this.NotFound(); - } - } - else if (!ServerConfiguration.Instance.Authentication.RegistrationEnabled) + if (!ServerConfiguration.Instance.Authentication.RegistrationEnabled) { return this.NotFound(); } diff --git a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs index 7ce506a3..882c12a0 100644 --- a/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs +++ b/ProjectLighthouse.Servers.Website/Pages/UserSettingsPage.cshtml.cs @@ -1,5 +1,4 @@ #nullable enable -using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Files; @@ -19,8 +18,6 @@ public class UserSettingsPage : BaseLayout public UserSettingsPage(Database database) : base(database) {} - private static bool IsValidEmail(string? email) => !string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email); - [SuppressMessage("ReSharper", "SpecifyStringComparison")] public async Task OnPost([FromRoute] int userId, [FromForm] string? avatar, [FromForm] string? username, [FromForm] string? email, [FromForm] string? biography, [FromForm] string? timeZone, [FromForm] string? language) { @@ -39,7 +36,9 @@ public class UserSettingsPage : BaseLayout if (this.ProfileUser.Biography != biography && biography.Length <= 512) this.ProfileUser.Biography = biography; - if (ServerConfiguration.Instance.Mail.MailEnabled && IsValidEmail(email) && (this.User == this.ProfileUser || this.User.IsAdmin)) + if (ServerConfiguration.Instance.Mail.MailEnabled && + SanitizationHelper.IsValidEmail(email) && + (this.User == this.ProfileUser || this.User.IsAdmin)) { // if email hasn't already been used if (!await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email!.ToLower())) diff --git a/ProjectLighthouse.Tests.GameApiTests/Tests/MatchTests.cs b/ProjectLighthouse.Tests.GameApiTests/Tests/MatchTests.cs index 6732f8ad..6f361c31 100644 --- a/ProjectLighthouse.Tests.GameApiTests/Tests/MatchTests.cs +++ b/ProjectLighthouse.Tests.GameApiTests/Tests/MatchTests.cs @@ -35,7 +35,7 @@ public class MatchTests : LighthouseServerTest await semaphore.WaitAsync(); HttpResponseMessage result = await this.AuthenticatedUploadDataRequest - ("LITTLEBIGPLANETPS3_XML/match", Encoding.ASCII.GetBytes("[UpdateMyPlayerData,[\"Player\":\"1984\"]]"), loginResult.AuthTicket); + ("LITTLEBIGPLANETPS3_XML/match", "[UpdateMyPlayerData,[\"Player\":\"1984\"]]"u8.ToArray(), loginResult.AuthTicket); semaphore.Release(); Assert.True(result.IsSuccessStatusCode); @@ -53,7 +53,7 @@ public class MatchTests : LighthouseServerTest int oldPlayerCount = await StatisticsHelper.RecentMatches(database); HttpResponseMessage result = await this.AuthenticatedUploadDataRequest - ("LITTLEBIGPLANETPS3_XML/match", Encoding.ASCII.GetBytes("[UpdateMyPlayerData,[\"Player\":\"1984\"]]"), loginResult.AuthTicket); + ("LITTLEBIGPLANETPS3_XML/match", "[UpdateMyPlayerData,[\"Player\":\"1984\"]]"u8.ToArray(), loginResult.AuthTicket); Assert.True(result.IsSuccessStatusCode); diff --git a/ProjectLighthouse.Tests.WebsiteTests/Tests/RegisterTests.cs b/ProjectLighthouse.Tests.WebsiteTests/Tests/RegisterTests.cs index be2561ca..96bdbefd 100644 --- a/ProjectLighthouse.Tests.WebsiteTests/Tests/RegisterTests.cs +++ b/ProjectLighthouse.Tests.WebsiteTests/Tests/RegisterTests.cs @@ -66,7 +66,7 @@ public class RegisterTests : LighthouseWebTest { await using Database database = new(); - string username = ("unitTestUser" + new Random().Next()).Substring(0, 16); + string username = ("unitTestUser" + new Random().Next())[..16]; string password = CryptoHelper.Sha256Hash(CryptoHelper.GenerateRandomBytes(64).ToArray()); await database.CreateUser(username, CryptoHelper.BCryptHash(password)); diff --git a/ProjectLighthouse.Tests/LighthouseServerTest.cs b/ProjectLighthouse.Tests/LighthouseServerTest.cs index cb3df9f1..a30352dc 100644 --- a/ProjectLighthouse.Tests/LighthouseServerTest.cs +++ b/ProjectLighthouse.Tests/LighthouseServerTest.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Xml.Serialization; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.PlayerData; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.Serialization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -33,7 +34,11 @@ public class LighthouseServerTest where TStartup : class { await using Database database = new(); if (await database.Users.FirstOrDefaultAsync(u => u.Username == $"{username}{number}") == null) - await database.CreateUser($"{username}{number}", CryptoHelper.BCryptHash($"unitTestPassword{number}")); + { + User user = await database.CreateUser($"{username}{number}", CryptoHelper.BCryptHash($"unitTestPassword{number}")); + user.LinkedPsnId = (ulong)number; + await database.SaveChangesAsync(); + } } //TODO: generate actual tickets @@ -44,7 +49,7 @@ public class LighthouseServerTest where TStartup : class return response; } - public async Task Authenticate(int number = 0) + public async Task Authenticate(int number = -1) { HttpResponseMessage response = await this.AuthenticateResponse(number); diff --git a/ProjectLighthouse.sln.DotSettings b/ProjectLighthouse.sln.DotSettings index e345dfeb..d3818928 100644 --- a/ProjectLighthouse.sln.DotSettings +++ b/ProjectLighthouse.sln.DotSettings @@ -149,6 +149,7 @@ True True True + True True True True diff --git a/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs b/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs index f39975a9..e4cd39d8 100644 --- a/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs +++ b/ProjectLighthouse/Configuration/ConfigurationCategories/AuthenticationConfiguration.cs @@ -5,6 +5,6 @@ namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories; public class AuthenticationConfiguration { public bool RegistrationEnabled { get; set; } = true; - public bool PrivateRegistration { get; set; } = false; - public bool UseExternalAuth { get; set; } + public bool AutomaticAccountCreation { get; set; } = true; + public bool VerifyTickets { get; set; } = true; } \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/Legacy/LegacyServerSettings.cs b/ProjectLighthouse/Configuration/Legacy/LegacyServerSettings.cs index 123fcf63..e1410b32 100644 --- a/ProjectLighthouse/Configuration/Legacy/LegacyServerSettings.cs +++ b/ProjectLighthouse/Configuration/Legacy/LegacyServerSettings.cs @@ -153,7 +153,6 @@ internal class LegacyServerSettings configuration.Authentication = new AuthenticationConfiguration { RegistrationEnabled = this.RegistrationEnabled, - UseExternalAuth = this.UseExternalAuth, }; configuration.Captcha = new CaptchaConfiguration diff --git a/ProjectLighthouse/Configuration/ServerConfiguration.cs b/ProjectLighthouse/Configuration/ServerConfiguration.cs index 466dfe03..729d42c7 100644 --- a/ProjectLighthouse/Configuration/ServerConfiguration.cs +++ b/ProjectLighthouse/Configuration/ServerConfiguration.cs @@ -23,7 +23,7 @@ public class ServerConfiguration // You can use an ObsoleteAttribute instead. Make sure you set it to error, though. // // Thanks for listening~ - public const int CurrentConfigVersion = 14; + public const int CurrentConfigVersion = 15; #region Meta diff --git a/ProjectLighthouse/Database.cs b/ProjectLighthouse/Database.cs index 676393dd..282877bb 100644 --- a/ProjectLighthouse/Database.cs +++ b/ProjectLighthouse/Database.cs @@ -40,7 +40,6 @@ public class Database : DbContext public DbSet LastContacts { get; set; } public DbSet VisitedLevels { get; set; } public DbSet RatedLevels { get; set; } - public DbSet AuthenticationAttempts { get; set; } public DbSet Reviews { get; set; } public DbSet RatedReviews { get; set; } public DbSet CustomCategories { get; set; } @@ -53,6 +52,7 @@ public class Database : DbContext public DbSet RegistrationTokens { get; set; } public DbSet APIKeys { get; set; } public DbSet Playlists { get; set; } + public DbSet PlatformLinkAttempts { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseMySql(ServerConfiguration.Instance.DbConnectionString, MySqlServerVersion.LatestSupportedServerVersion); @@ -102,9 +102,8 @@ public class Database : DbContext return user; } - public async Task AuthenticateUser(NPTicket npTicket, string userLocation) + public async Task AuthenticateUser(User? user, NPTicket npTicket, string userLocation) { - User? user = await this.Users.FirstOrDefaultAsync(u => u.Username == npTicket.Username); if (user == null) return null; GameToken gameToken = new() @@ -115,6 +114,7 @@ public class Database : DbContext UserLocation = userLocation, GameVersion = npTicket.GameVersion, Platform = npTicket.Platform, + TicketHash = npTicket.TicketHash, // we can get away with a low expiry here since LBP will just get a new token everytime it gets 403'd ExpiresAt = DateTime.Now + TimeSpan.FromHours(1), }; @@ -341,13 +341,11 @@ public class Database : DbContext return await this.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId); } - private async Task UserFromMMAuth(string authToken, bool allowUnapproved = false) + private async Task UserFromMMAuth(string authToken) { - if (ServerStatics.IsUnitTesting) allowUnapproved = true; GameToken? token = await this.GameTokens.FirstOrDefaultAsync(t => t.UserToken == authToken); if (token == null) return null; - if (!allowUnapproved && !token.Approved) return null; if (DateTime.Now <= token.ExpiresAt) return await this.Users.FirstOrDefaultAsync(u => u.UserId == token.UserId); @@ -357,23 +355,20 @@ public class Database : DbContext return null; } - public async Task UserFromGameRequest(HttpRequest request, bool allowUnapproved = false) + public async Task UserFromGameRequest(HttpRequest request) { - if (ServerStatics.IsUnitTesting) allowUnapproved = true; - if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth) || mmAuth == null) return null; + if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth)) return null; - return await this.UserFromMMAuth(mmAuth, allowUnapproved); + return await this.UserFromMMAuth(mmAuth); } - public async Task GameTokenFromRequest(HttpRequest request, bool allowUnapproved = false) + public async Task GameTokenFromRequest(HttpRequest request) { - if (ServerStatics.IsUnitTesting) allowUnapproved = true; - if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth) || mmAuth == null) return null; + if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth)) return null; GameToken? token = await this.GameTokens.FirstOrDefaultAsync(t => t.UserToken == mmAuth); if (token == null) return null; - if (!allowUnapproved && !token.Approved) return null; if (DateTime.Now <= token.ExpiresAt) return token; @@ -383,14 +378,12 @@ public class Database : DbContext return null; } - public async Task<(User, GameToken)?> UserAndGameTokenFromRequest(HttpRequest request, bool allowUnapproved = false) + public async Task<(User, GameToken)?> UserAndGameTokenFromRequest(HttpRequest request) { - if (ServerStatics.IsUnitTesting) allowUnapproved = true; - if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth) || mmAuth == null) return null; + if (!request.Cookies.TryGetValue("MM_AUTH", out string? mmAuth)) return null; GameToken? token = await this.GameTokens.FirstOrDefaultAsync(t => t.UserToken == mmAuth); if (token == null) return null; - if (!allowUnapproved && !token.Approved) return null; if (DateTime.Now > token.ExpiresAt) { @@ -527,7 +520,6 @@ public class Database : DbContext foreach (Slot slot in this.Slots.Where(s => s.CreatorId == user.UserId)) await this.RemoveSlot(slot, false); - this.AuthenticationAttempts.RemoveRange(this.AuthenticationAttempts.Include(a => a.GameToken).Where(a => a.GameToken.UserId == user.UserId)); this.HeartedProfiles.RemoveRange(this.HeartedProfiles.Where(h => h.UserId == user.UserId)); this.PhotoSubjects.RemoveRange(this.PhotoSubjects.Where(s => s.UserId == user.UserId)); this.HeartedLevels.RemoveRange(this.HeartedLevels.Where(h => h.UserId == user.UserId)); diff --git a/ProjectLighthouse/Helpers/CryptoHelper.cs b/ProjectLighthouse/Helpers/CryptoHelper.cs index b29841e7..116289ef 100644 --- a/ProjectLighthouse/Helpers/CryptoHelper.cs +++ b/ProjectLighthouse/Helpers/CryptoHelper.cs @@ -170,7 +170,7 @@ public static class CryptoHelper public static string Sha1Hash(string str) => Sha1Hash(Encoding.UTF8.GetBytes(str)); - public static string Sha1Hash(byte[] bytes) => BitConverter.ToString(SHA1.Create().ComputeHash(bytes)).Replace("-", ""); + public static string Sha1Hash(byte[] bytes) => BitConverter.ToString(SHA1.HashData(bytes)).Replace("-", ""); public static string BCryptHash(string str) => BCrypt.Net.BCrypt.HashPassword(str); diff --git a/ProjectLighthouse/Helpers/SanitizationHelper.cs b/ProjectLighthouse/Helpers/SanitizationHelper.cs index 20273ff2..d78ffceb 100644 --- a/ProjectLighthouse/Helpers/SanitizationHelper.cs +++ b/ProjectLighthouse/Helpers/SanitizationHelper.cs @@ -1,5 +1,6 @@ #nullable enable using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; @@ -15,6 +16,10 @@ public static class SanitizationHelper {"'", "'"}, }; + public static bool IsValidEmail + (string? email) => + !string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email); + public static void SanitizeStringsInClass(object? instance) { if (instance == null) return; diff --git a/ProjectLighthouse/Migrations/20221217002014_ReworkGameTokens.cs b/ProjectLighthouse/Migrations/20221217002014_ReworkGameTokens.cs new file mode 100644 index 00000000..abba900e --- /dev/null +++ b/ProjectLighthouse/Migrations/20221217002014_ReworkGameTokens.cs @@ -0,0 +1,118 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20221217002014_ReworkGameTokens")] + public partial class ReworkGameTokens : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuthenticationAttempts"); + + migrationBuilder.DropColumn( + name: "ApprovedIPAddress", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Approved", + table: "GameTokens"); + + migrationBuilder.DropColumn( + name: "Used", + table: "GameTokens"); + + migrationBuilder.AddColumn( + name: "LinkedPsnId", + table: "Users", + type: "bigint unsigned", + nullable: false, + defaultValue: 0ul); + + migrationBuilder.AddColumn( + name: "LinkedRpcnId", + table: "Users", + type: "bigint unsigned", + nullable: false, + defaultValue: 0ul); + + migrationBuilder.AddColumn( + name: "TicketHash", + table: "GameTokens", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LinkedPsnId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "LinkedRpcnId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TicketHash", + table: "GameTokens"); + + migrationBuilder.AddColumn( + name: "ApprovedIPAddress", + table: "Users", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "Approved", + table: "GameTokens", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Used", + table: "GameTokens", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "AuthenticationAttempts", + columns: table => new + { + AuthenticationAttemptId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + GameTokenId = table.Column(type: "int", nullable: false), + IPAddress = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Platform = table.Column(type: "int", nullable: false), + Timestamp = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuthenticationAttempts", x => x.AuthenticationAttemptId); + table.ForeignKey( + name: "FK_AuthenticationAttempts_GameTokens_GameTokenId", + column: x => x.GameTokenId, + principalTable: "GameTokens", + principalColumn: "TokenId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_AuthenticationAttempts_GameTokenId", + table: "AuthenticationAttempts", + column: "GameTokenId"); + } + } +} diff --git a/ProjectLighthouse/Migrations/20221217043015_AddPlatformLinkAttempts.cs b/ProjectLighthouse/Migrations/20221217043015_AddPlatformLinkAttempts.cs new file mode 100644 index 00000000..2fd2ba51 --- /dev/null +++ b/ProjectLighthouse/Migrations/20221217043015_AddPlatformLinkAttempts.cs @@ -0,0 +1,42 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20221217043015_AddPlatformLinkAttempts")] + public partial class AddPlatformLinkAttempts : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlatformLinkAttempts", + columns: table => new + { + PlatformLinkAttemptId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "int", nullable: false), + PlatformId = table.Column(type: "bigint unsigned", nullable: false), + Platform = table.Column(type: "int", nullable: false), + Timestamp = table.Column(type: "bigint", nullable: false), + IPAddress = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_PlatformLinkAttempts", x => x.PlatformLinkAttemptId); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlatformLinkAttempts"); + } + } +} diff --git a/ProjectLighthouse/Migrations/20221217044751_AddForeignKeyToLinkAttempts.cs b/ProjectLighthouse/Migrations/20221217044751_AddForeignKeyToLinkAttempts.cs new file mode 100644 index 00000000..c7fbefbf --- /dev/null +++ b/ProjectLighthouse/Migrations/20221217044751_AddForeignKeyToLinkAttempts.cs @@ -0,0 +1,40 @@ +using LBPUnion.ProjectLighthouse; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20221217044751_AddForeignKeyToLinkAttempts")] + public partial class AddForeignKeyToLinkAttempts : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_PlatformLinkAttempts_UserId", + table: "PlatformLinkAttempts", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_PlatformLinkAttempts_Users_UserId", + table: "PlatformLinkAttempts", + column: "UserId", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_PlatformLinkAttempts_Users_UserId", + table: "PlatformLinkAttempts"); + + migrationBuilder.DropIndex( + name: "IX_PlatformLinkAttempts_UserId", + table: "PlatformLinkAttempts"); + } + } +} diff --git a/ProjectLighthouse/PlayerData/AuthenticationAttempt.cs b/ProjectLighthouse/PlayerData/AuthenticationAttempt.cs deleted file mode 100644 index 96eefad0..00000000 --- a/ProjectLighthouse/PlayerData/AuthenticationAttempt.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace LBPUnion.ProjectLighthouse.PlayerData; - -public class AuthenticationAttempt -{ - [Key] - public int AuthenticationAttemptId { get; set; } - - public long Timestamp { get; set; } - public Platform Platform { get; set; } - public string IPAddress { get; set; } - - public int GameTokenId { get; set; } - - [ForeignKey(nameof(GameTokenId))] - public GameToken GameToken { get; set; } -} \ No newline at end of file diff --git a/ProjectLighthouse/PlayerData/GameToken.cs b/ProjectLighthouse/PlayerData/GameToken.cs index 1317b34f..082a21b0 100644 --- a/ProjectLighthouse/PlayerData/GameToken.cs +++ b/ProjectLighthouse/PlayerData/GameToken.cs @@ -24,11 +24,7 @@ public class GameToken public Platform Platform { get; set; } - // Set by /authentication webpage - public bool Approved { get; set; } - - // Set to true on login - public bool Used { get; set; } + public string TicketHash { get; set; } public DateTime ExpiresAt { get; set; } } \ No newline at end of file diff --git a/ProjectLighthouse/PlayerData/PlatformLinkAttempt.cs b/ProjectLighthouse/PlayerData/PlatformLinkAttempt.cs new file mode 100644 index 00000000..d6f661a2 --- /dev/null +++ b/ProjectLighthouse/PlayerData/PlatformLinkAttempt.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using LBPUnion.ProjectLighthouse.PlayerData.Profiles; + +namespace LBPUnion.ProjectLighthouse.PlayerData; + +public class PlatformLinkAttempt +{ + [Key] + public int PlatformLinkAttemptId { get; set; } + + [ForeignKey(nameof(UserId))] + public User User { get; set; } + + public int UserId { get; set; } + + public ulong PlatformId { get; set; } + + public Platform Platform { get; set; } + + public long Timestamp { get; set; } + + public string IPAddress { get; set; } = ""; + +} \ No newline at end of file diff --git a/ProjectLighthouse/PlayerData/Profiles/User.cs b/ProjectLighthouse/PlayerData/Profiles/User.cs index cf9b5104..f400ad09 100644 --- a/ProjectLighthouse/PlayerData/Profiles/User.cs +++ b/ProjectLighthouse/PlayerData/Profiles/User.cs @@ -87,15 +87,14 @@ public class User private int PhotosWithMe() { List photoSubjectIds = new(); - photoSubjectIds.AddRange(this.database.PhotoSubjects.Where(p => p.UserId == this.UserId).Select(p => p.PhotoSubjectId)); + photoSubjectIds.AddRange(this.database.PhotoSubjects.Where(p => p.UserId == this.UserId) + .Select(p => p.PhotoSubjectId)); - var list = this.database.Photos.Select(p => new - { - p.PhotoId, - p.PhotoSubjectCollection, - }).ToList(); - List photoIds = (from v in list where photoSubjectIds.Any(ps => v.PhotoSubjectCollection.Split(",").Contains(ps.ToString())) select v.PhotoId).ToList(); - return this.database.Photos.Count(p => photoIds.Any(pId => p.PhotoId == pId) && p.CreatorId != this.UserId); + return ( + from id in photoSubjectIds + from photo in this.database.Photos.Where(p => p.PhotoSubjectCollection.Contains(id.ToString())).ToList() + where photo.PhotoSubjectCollection.Split(",").Contains(id.ToString()) && photo.CreatorId != this.UserId + select id).Count(); } [JsonIgnore] @@ -179,11 +178,6 @@ public class User public string? BannedReason { get; set; } #nullable disable - #nullable enable - [JsonIgnore] - public string? ApprovedIPAddress { get; set; } - #nullable disable - [JsonIgnore] public string Language { get; set; } = "en"; @@ -207,6 +201,12 @@ public class User [JsonIgnore] public string TwoFactorBackup { get; set; } = ""; + [JsonIgnore] + public ulong LinkedRpcnId { get; set; } + + [JsonIgnore] + public ulong LinkedPsnId { get; set; } + // should not be adjustable by user public bool CommentsEnabled { get; set; } = true; diff --git a/ProjectLighthouse/ProjectLighthouse.csproj b/ProjectLighthouse/ProjectLighthouse.csproj index 16d96f64..8903fea8 100644 --- a/ProjectLighthouse/ProjectLighthouse.csproj +++ b/ProjectLighthouse/ProjectLighthouse.csproj @@ -26,6 +26,7 @@ + diff --git a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs index 74203cc0..91cf862d 100644 --- a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs +++ b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs @@ -16,7 +16,7 @@ namespace ProjectLighthouse.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.10") + .HasAnnotation("ProductVersion", "7.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("LBPUnion.ProjectLighthouse.Administration.CompletedMigration", b => @@ -457,40 +457,12 @@ namespace ProjectLighthouse.Migrations b.ToTable("APIKeys"); }); - modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.AuthenticationAttempt", b => - { - b.Property("AuthenticationAttemptId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - b.Property("GameTokenId") - .HasColumnType("int"); - - b.Property("IPAddress") - .HasColumnType("longtext"); - - b.Property("Platform") - .HasColumnType("int"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.HasKey("AuthenticationAttemptId"); - - b.HasIndex("GameTokenId"); - - b.ToTable("AuthenticationAttempts"); - }); - modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.GameToken", b => { b.Property("TokenId") .ValueGeneratedOnAdd() .HasColumnType("int"); - b.Property("Approved") - .HasColumnType("tinyint(1)"); - b.Property("ExpiresAt") .HasColumnType("datetime(6)"); @@ -500,8 +472,8 @@ namespace ProjectLighthouse.Migrations b.Property("Platform") .HasColumnType("int"); - b.Property("Used") - .HasColumnType("tinyint(1)"); + b.Property("TicketHash") + .HasColumnType("longtext"); b.Property("UserId") .HasColumnType("int"); @@ -602,6 +574,34 @@ namespace ProjectLighthouse.Migrations b.ToTable("PhotoSubjects"); }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.PlatformLinkAttempt", b => + { + b.Property("PlatformLinkAttemptId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("IPAddress") + .HasColumnType("longtext"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformId") + .HasColumnType("bigint unsigned"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("PlatformLinkAttemptId"); + + b.HasIndex("UserId"); + + b.ToTable("PlatformLinkAttempts"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.Profiles.Comment", b => { b.Property("CommentId") @@ -755,9 +755,6 @@ namespace ProjectLighthouse.Migrations b.Property("AdminGrantedSlots") .HasColumnType("int"); - b.Property("ApprovedIPAddress") - .HasColumnType("longtext"); - b.Property("BannedReason") .HasColumnType("longtext"); @@ -794,6 +791,12 @@ namespace ProjectLighthouse.Migrations b.Property("LevelVisibility") .HasColumnType("int"); + b.Property("LinkedPsnId") + .HasColumnType("bigint unsigned"); + + b.Property("LinkedRpcnId") + .HasColumnType("bigint unsigned"); + b.Property("LocationId") .HasColumnType("int"); @@ -1165,17 +1168,6 @@ namespace ProjectLighthouse.Migrations b.Navigation("User"); }); - modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.AuthenticationAttempt", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.PlayerData.GameToken", "GameToken") - .WithMany() - .HasForeignKey("GameTokenId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("GameToken"); - }); - modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.GameToken", b => { b.HasOne("LBPUnion.ProjectLighthouse.PlayerData.Profiles.User", "User") @@ -1215,6 +1207,17 @@ namespace ProjectLighthouse.Migrations b.Navigation("User"); }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.PlatformLinkAttempt", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.PlayerData.Profiles.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.Profiles.Comment", b => { b.HasOne("LBPUnion.ProjectLighthouse.PlayerData.Profiles.User", "Poster") diff --git a/ProjectLighthouse/Tickets/NPTicket.cs b/ProjectLighthouse/Tickets/NPTicket.cs index 84fa0723..09a277b9 100644 --- a/ProjectLighthouse/Tickets/NPTicket.cs +++ b/ProjectLighthouse/Tickets/NPTicket.cs @@ -1,15 +1,26 @@ #nullable enable using System; +using System.Collections.Generic; using System.IO; -using System.Text; -using System.Text.Json; -using LBPUnion.ProjectLighthouse.Configuration; +using System.Linq; using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.PlayerData; using LBPUnion.ProjectLighthouse.Tickets.Data; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Math.EC; +using Org.BouncyCastle.Security; using Version = LBPUnion.ProjectLighthouse.Types.Version; +#if DEBUG +using System.Text; +using System.Text.Json; +using LBPUnion.ProjectLighthouse.Configuration; +#endif namespace LBPUnion.ProjectLighthouse.Tickets; @@ -27,20 +38,89 @@ public class NPTicket public uint IssuerId { get; set; } public ulong IssuedDate { get; set; } public ulong ExpireDate { get; set; } + public ulong UserId { get; set; } + public string TicketHash { get; set; } = ""; private string? titleId { get; set; } + private byte[] ticketBody { get; set; } = Array.Empty(); + + private byte[] ticketSignature { get; set; } = Array.Empty(); + public GameVersion GameVersion { get; set; } - private static void Read21Ticket(NPTicket npTicket, TicketReader reader) + private static ECDomainParameters FromX9EcParams(X9ECParameters param) => + new(param.Curve, param.G, param.N, param.H, param.GetSeed()); + + private static readonly ECDomainParameters secp224K1 = FromX9EcParams(ECNamedCurveTable.GetByName("secp224k1")); + private static readonly ECDomainParameters secp192R1 = FromX9EcParams(ECNamedCurveTable.GetByName("secp192r1")); + + private static readonly ECPoint rpcnPublic = secp224K1.Curve.CreatePoint( + new BigInteger("b07bc0f0addb97657e9f389039e8d2b9c97dc2a31d3042e7d0479b93", 16), + new BigInteger("d81c42b0abdf6c42191a31e31f93342f8f033bd529c2c57fdb5a0a7d", 16)); + + private static readonly ECPoint psnPublic = secp192R1.Curve.CreatePoint( + new BigInteger("39c62d061d4ee35c5f3f7531de0af3cf918346526edac727", 16), + new BigInteger("a5d578b55113e612bf1878d4cc939d61a41318403b5bdf86", 16)); + + private ECDomainParameters getCurveParams() => this.IsRpcn() ? secp224K1 : secp192R1; + + private ECPoint getPublicKey() => this.IsRpcn() ? rpcnPublic : psnPublic; + + private bool ValidateSignature() { - reader.ReadTicketString(); // "Serial id", but its apparently not what we're looking for + ECPublicKeyParameters pubKey = new(this.getPublicKey(), this.getCurveParams()); + string algo = this.IsRpcn() ? "SHA-224" : "SHA-1"; + + ISigner signer = SignerUtilities.GetSigner($"{algo}withECDSA"); + signer.Init(false, pubKey); + + signer.BlockUpdate(this.ticketBody); + + return signer.VerifySignature(this.ticketSignature); + } + + private bool IsRpcn() => this.IssuerId == 0x33333333; + + private static readonly Dictionary identifierByPlatform = new() + { + { + Platform.RPCS3, "RPCN"u8.ToArray() + }, + { + Platform.PS3, new byte[]{ 0x71, 0x9F, 0x1D, 0x4A, } + }, + }; + + // Sometimes psn signatures have one or two extra empty bytes + // This is slow but it's better than carelessly chopping 0's + private static byte[] ParseSignature(byte[] signature) + { + for (int i = 0; i <= 2; i++) + { + try + { + Asn1Object.FromByteArray(signature); + break; + } + catch + { + signature = signature.SkipLast(1).ToArray(); + } + } + + return signature; + } + + private static bool Read21Ticket(NPTicket npTicket, TicketReader reader) + { + reader.ReadTicketString(); // serial id npTicket.IssuerId = reader.ReadTicketUInt32(); npTicket.IssuedDate = reader.ReadTicketUInt64(); npTicket.ExpireDate = reader.ReadTicketUInt64(); - reader.ReadTicketUInt64(); // PSN User id, we don't care about this + npTicket.UserId = reader.ReadTicketUInt64(); npTicket.Username = reader.ReadTicketString(); @@ -48,12 +128,99 @@ public class NPTicket reader.ReadTicketString(); // Domain npTicket.titleId = reader.ReadTicketString(); + + reader.ReadTicketUInt32(); // status + + reader.ReadTicketEmpty(); // padding + reader.ReadTicketEmpty(); + + reader.ReadSectionHeader(); // footer header + + byte[] ticketIdent = reader.ReadTicketBinary(); // 4 byte identifier + Platform platform = npTicket.IsRpcn() ? Platform.RPCS3 : Platform.PS3; + byte[] platformIdent = identifierByPlatform[platform]; + if (!ticketIdent.SequenceEqual(platformIdent)) + { + Logger.Warn(@$"Identity sequence mismatch, platform={npTicket.Platform} - {Convert.ToHexString(ticketIdent)} == {Convert.ToHexString(platformIdent)}", LogArea.Login); + return false; + } + + npTicket.ticketSignature = ParseSignature(reader.ReadTicketBinary()); + return true; } - // Function is here for future use incase we ever need to read more from the ticket - private static void Read30Ticket(NPTicket npTicket, TicketReader reader) + private static bool Read30Ticket(NPTicket npTicket, TicketReader reader) { - Read21Ticket(npTicket, reader); + reader.ReadTicketString(); // serial id + + npTicket.IssuerId = reader.ReadTicketUInt32(); + npTicket.IssuedDate = reader.ReadTicketUInt64(); + npTicket.ExpireDate = reader.ReadTicketUInt64(); + + npTicket.UserId = reader.ReadTicketUInt64(); + + npTicket.Username = reader.ReadTicketString(); + + reader.ReadTicketString(); // Country + reader.ReadTicketString(); // Domain + + npTicket.titleId = reader.ReadTicketString(); + + reader.ReadSectionHeader(); // date of birth section + reader.ReadBytes(4); // 4 bytes for year month and day + reader.ReadTicketUInt32(); + + reader.ReadSectionHeader(); // empty section? + reader.ReadTicketEmpty(); + + reader.ReadSectionHeader(); // footer header + + byte[] ticketIdent = reader.ReadTicketBinary(); // 4 byte identifier + Platform platform = npTicket.IsRpcn() ? Platform.RPCS3 : Platform.PS3; + byte[] platformIdent = identifierByPlatform[platform]; + if (!ticketIdent.SequenceEqual(platformIdent)) + { + Logger.Warn(@$"Identity sequence mismatch, platform={npTicket.Platform} - {Convert.ToHexString(ticketIdent)} == {Convert.ToHexString(platformIdent)}", LogArea.Login); + return false; + } + + npTicket.ticketSignature = ParseSignature(reader.ReadTicketBinary()); + return true; + } + + private static bool ReadTicket(byte[] data, NPTicket npTicket, TicketReader reader) + { + npTicket.ticketVersion = reader.ReadTicketVersion(); + + reader.ReadBytes(4); // Skip header + + ushort ticketLen = reader.ReadUInt16BE(); + // Subtract 8 bytes to account for ticket header + if (ticketLen != data.Length - 0x8) + { + Logger.Warn(@$"Ticket length mismatch, expected={ticketLen}, actual={data.Length - 0x8}", LogArea.Login); + return false; + } + + long bodyStart = reader.BaseStream.Position; + SectionHeader bodyHeader = reader.ReadSectionHeader(); + + Logger.Debug($"bodyHeader.Type is {bodyHeader.Type}, index={bodyStart}", LogArea.Login); + + bool parsedSuccessfully = npTicket.ticketVersion.ToString() switch + { + "2.1" => Read21Ticket(npTicket, reader), // used by ps3 + "3.0" => Read30Ticket(npTicket, reader), // used by ps vita + _ => throw new NotImplementedException(), + }; + + if (!parsedSuccessfully) return false; + + npTicket.ticketBody = npTicket.IsRpcn() + ? data.AsSpan().Slice((int)bodyStart, bodyHeader.Length + 4).ToArray() + : data.AsSpan()[..data.AsSpan().IndexOf(npTicket.ticketSignature)].ToArray(); + + return true; } /// @@ -76,10 +243,10 @@ public class NPTicket GameVersion = GameVersion.LittleBigPlanet2, ExpireDate = 0, IssuedDate = 0, + Username = dataStr["unitTestTicket".Length..], + UserId = ulong.Parse(dataStr["unitTestTicketunitTestUser".Length..]), }; - npTicket.Username = dataStr.Substring(14); - return npTicket; } } @@ -89,24 +256,22 @@ public class NPTicket using MemoryStream ms = new(data); using TicketReader reader = new(ms); - npTicket.ticketVersion = reader.ReadTicketVersion(); - - reader.ReadBytes(4); // Skip header - - reader.ReadUInt16BE(); // Ticket length, we don't care about this - - SectionHeader bodyHeader = reader.ReadSectionHeader(); - Logger.Debug($"bodyHeader.Type is {bodyHeader.Type}", LogArea.Login); - - switch (npTicket.ticketVersion) + bool validTicket = ReadTicket(data, npTicket, reader); + if (!validTicket) { - case "2.1": - Read21Ticket(npTicket, reader); - break; - case "3.0": - Read30Ticket(npTicket, reader); - break; - default: throw new NotImplementedException(); + Logger.Warn($"Failed to parse ticket from {npTicket.Username}", LogArea.Login); + return null; + } + + if ((long)npTicket.IssuedDate > TimeHelper.TimestampMillis) + { + Logger.Warn($"Ticket isn't valid yet from {npTicket.Username}", LogArea.Login); + return null; + } + if (TimeHelper.TimestampMillis > (long)npTicket.ExpireDate) + { + Logger.Warn($"Ticket has expired from {npTicket.Username}", LogArea.Login); + return null; } if (npTicket.titleId == null) throw new ArgumentNullException($"{nameof(npTicket)}.{nameof(npTicket.titleId)}"); @@ -114,8 +279,8 @@ public class NPTicket // We already read the title id, however we need to do some post-processing to get what we want. // Current data: UP9000-BCUS98245_00 // We need to chop this to get the titleId we're looking for - npTicket.titleId = npTicket.titleId.Substring(7); // Trim UP9000- - npTicket.titleId = npTicket.titleId.Substring(0, npTicket.titleId.Length - 3); // Trim _00 at the end + npTicket.titleId = npTicket.titleId[7..]; // Trim UP9000- + npTicket.titleId = npTicket.titleId[..^3]; // Trim _00 at the end // Data now (hopefully): BCUS98245 Logger.Debug($"titleId is {npTicket.titleId}", LogArea.Login); @@ -145,6 +310,15 @@ public class NPTicket return null; } + if (ServerConfiguration.Instance.Authentication.VerifyTickets && !npTicket.ValidateSignature()) + { + Logger.Warn($"Failed to verify authenticity of ticket from user {npTicket.Username}", LogArea.Login); + return null; + } + + // Used to identify duplicate tickets + npTicket.TicketHash = CryptoHelper.Sha1Hash(data); + #if DEBUG Logger.Debug("npTicket data:", LogArea.Login); Logger.Debug(JsonSerializer.Serialize(npTicket), LogArea.Login); @@ -160,7 +334,6 @@ public class NPTicket "Please let us know that this is a ticket version that is actually used on our issue tracker at https://github.com/LBPUnion/project-lighthouse/issues !", LogArea.Login ); - return null; } catch(Exception e) diff --git a/ProjectLighthouse/Tickets/TicketReader.cs b/ProjectLighthouse/Tickets/TicketReader.cs index cfb7c067..d7928f33 100644 --- a/ProjectLighthouse/Tickets/TicketReader.cs +++ b/ProjectLighthouse/Tickets/TicketReader.cs @@ -19,18 +19,22 @@ public class TicketReader : BinaryReader { this.ReadByte(); - SectionHeader sectionHeader = new(); - sectionHeader.Type = (SectionType)this.ReadByte(); - sectionHeader.Length = this.ReadUInt16BE(); + SectionHeader sectionHeader = new() + { + Type = (SectionType)this.ReadByte(), + Length = this.ReadUInt16BE(), + }; return sectionHeader; } - public DataHeader ReadDataHeader() + private DataHeader ReadDataHeader() { - DataHeader dataHeader = new(); - dataHeader.Type = (DataType)this.ReadUInt16BE(); - dataHeader.Length = this.ReadUInt16BE(); + DataHeader dataHeader = new() + { + Type = (DataType)this.ReadUInt16BE(), + Length = this.ReadUInt16BE(), + }; return dataHeader; } @@ -38,7 +42,7 @@ public class TicketReader : BinaryReader public byte[] ReadTicketBinary() { DataHeader dataHeader = this.ReadDataHeader(); - Debug.Assert(dataHeader.Type == DataType.Binary || dataHeader.Type == DataType.String); + Debug.Assert(dataHeader.Type is DataType.Binary or DataType.String); return this.ReadBytes(dataHeader.Length); } @@ -53,10 +57,16 @@ public class TicketReader : BinaryReader return this.ReadUInt32BE(); } + public void ReadTicketEmpty() + { + DataHeader dataHeader = this.ReadDataHeader(); + Debug.Assert(dataHeader.Type == DataType.Empty); + } + public ulong ReadTicketUInt64() { DataHeader dataHeader = this.ReadDataHeader(); - Debug.Assert(dataHeader.Type == DataType.UInt64 || dataHeader.Type == DataType.Timestamp); + Debug.Assert(dataHeader.Type is DataType.UInt64 or DataType.Timestamp); return this.ReadUInt64BE(); }