Rework login and registration systems (#600)

* Initial work for verifying login ticket signatures

* Add candidate psn public key

* Add candidate psn public key and fix nuget packages

* Finalize npticket changes

* Add support for ticket version 3.0

* Rework login system to link platform accounts instead of using ip addresses

* Make linked accounts green instead of blue

* Fix api building

* Fix unit tests

* Actually fix unit tests

* Set unit test user's linked platform

* Why was this the wrong default value?

* Fix username change code

* Make TicketHash hash the entire ticket instead of just the serial

* Send password setup email when user sets their email for the first time

* Changes from self review
This commit is contained in:
Josh 2022-12-26 03:03:14 -06:00 committed by GitHub
parent ff7969a147
commit 19ea44e0e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 836 additions and 449 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "7.0.0",
"version": "7.0.1",
"commands": [
"dotnet-ef"
]

View file

@ -71,8 +71,7 @@ public class UserEndpoints : ApiEndpointController
[HttpPost("user/inviteToken/{username}")]
public async Task<IActionResult> 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"];

View file

@ -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);
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)
{
ulong targetPlatform = npTicket.Platform == Platform.RPCS3
? targetUsername.LinkedRpcnId
: targetUsername.LinkedPsnId;
// 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()
{
Platform = npTicket.Platform,
UserId = targetUsername.UserId,
IPAddress = ipAddress,
Timestamp = TimeHelper.TimestampMillis,
PlatformId = npTicket.UserId,
};
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);
}
// automatically change username if it doesn't match
else if (user.Username != npTicket.Username)
{
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();
}
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 != null)
{
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 not, then 403.
}
}
// 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)
if (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()
{
GameToken = token,
GameTokenId = token.TokenId,
Timestamp = TimeHelper.Timestamp,
IPAddress = ipAddress,
Platform = npTicket.Platform,
};
this.database.AuthenticationAttempts.Add(authAttempt);
}
}
else
{
token.Approved = true;
}
await this.database.SaveChangesAsync();
if (!token.Approved)
{
Logger.Warn($"Token unapproved for user {user.Username}, rejecting login", LogArea.Login);
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;

View file

@ -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 <https://www.gnu.org/licenses/>.";
#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 <https://www.gnu.org/licenses/>.";
{
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);
}

View file

@ -188,20 +188,14 @@ public class PhotosController : ControllerBase
List<int> photoSubjectIds = new();
photoSubjectIds.AddRange(this.database.PhotoSubjects.Where(p => p.UserId == targetUserId).Select(p => p.PhotoSubjectId));
List<Photo> 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<int> 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)
string response = photos
.OrderByDescending(s => s.Timestamp)
.Skip(Math.Max(0, pageStart - 1))
.Take(Math.Min(pageSize, 30)),
string.Empty,
.Take(Math.Min(pageSize, 30)).Aggregate(string.Empty,
(current, photo) => current + photo.Serialize());
return this.Ok(LbpSerializer.StringElement("photos", response));

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> DenyAll()
{
User? user = this.database.UserFromWebRequest(this.Request);
if (user == null) return this.Redirect("/login");
List<AuthenticationAttempt> 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();

View file

@ -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<IActionResult> 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<IActionResult> 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");
}
}

View file

@ -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)
{
<p>You have no pending authentication attempts.</p>
<p>You have no pending link attempts.</p>
}
else
{
<p>You have @Model.AuthenticationAttempts.Count authentication attempts pending.</p>
<p>You have @Model.LinkAttempts.Count authentication attempts pending.</p>
@if (Model.IpAddress != null)
{
<p>This device's IP address is <b>@(Model.IpAddress.ToString())</b>. 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.</p>
<p>This device's IP address is <b>@(Model.IpAddress.ToString())</b>. 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.</p>
}
}
@if (Model.User!.ApprovedIPAddress != null)
@foreach (PlatformLinkAttempt authAttempt in Model.LinkAttempts)
{
<a href="/authentication/revokeAutoApproval">
<button class="ui red button">
<i class="trash icon"></i>
<span>Revoke automatically approved IP Address (@Model.User!.ApprovedIPAddress)</span>
</button>
</a>
}
@if (Model.AuthenticationAttempts.Count > 1)
{
<a href="/authentication/denyAll">
<button class="ui red button">
<i class="x icon"></i>
<span>Deny all</span>
</button>
</a>
}
@foreach (AuthenticationAttempt authAttempt in Model.AuthenticationAttempts)
{
DateTimeOffset timestamp = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(authAttempt.Timestamp), timeZoneInfo);
DateTimeOffset timestamp = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeMilliseconds(authAttempt.Timestamp), timeZoneInfo);
<div class="ui red segment">
<p>A <b>@authAttempt.Platform</b> authentication request was logged at <b>@timestamp.ToString("M/d/yyyy @ h:mm tt")</b> from the IP address <b>@authAttempt.IPAddress</b>.</p>
<p>A <b>@authAttempt.Platform</b> link request was logged at <b>@timestamp.ToString("M/d/yyyy @ h:mm tt")</b> from the IP address <b>@authAttempt.IPAddress</b>.</p>
<p><i class="yellow warning icon"></i> If you approve this request it will override any other linked accounts you have</p>
<div>
<a href="/authentication/autoApprove/@authAttempt.AuthenticationAttemptId">
<a href="/authentication/approve/@authAttempt.PlatformLinkAttemptId">
<button class="ui small green button">
<i class="check icon"></i>
<span>Automatically approve every time</span>
<span>Approve</span>
</button>
</a>
<a href="/authentication/approve/@authAttempt.AuthenticationAttemptId">
<button class="ui small yellow button">
<i class="check icon"></i>
<span>Approve this time</span>
</button>
</a>
<a href="/authentication/deny/@authAttempt.AuthenticationAttemptId">
<a href="/authentication/deny/@authAttempt.PlatformLinkAttemptId">
<button class="ui small red button">
<i class="x icon"></i>
<span>Deny</span>
@ -68,3 +44,41 @@ else
</div>
</div>
}
<div style="display: inline-block">
<h3 style="display: inline-block">PSN: </h3>
@if (Model.User?.LinkedPsnId != 0)
{
<div class="ui green button" style="cursor: default; pointer-events: none">
Linked
</div>
<a href="/authentication/unlink/psn" style="display: block; color: orangered">
Click here to unlink this platform
</a>
}
else
{
<button class="ui button" style="cursor: default; pointer-events: none">
Unlinked
</button>
}
</div>
<div class="ui divider"></div>
<div>
<h3 style="display: inline-block">RPCN: </h3>
@if (Model.User?.LinkedRpcnId != 0)
{
<div class="ui green button" style="cursor: default; pointer-events: none">
Linked
</div>
<a href="/authentication/unlink/rpcn" style="display: block; color: orangered">
Click here to unlink this platform
</a>
}
else
{
<button class="ui button" style="cursor: default; pointer-events: none">
Unlinked
</button>
}
</div>

View file

@ -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<AuthenticationAttempt> AuthenticationAttempts = new();
public List<PlatformLinkAttempt> LinkAttempts = new();
public IPAddress? IpAddress;
public AuthenticationPage(Database database) : base(database)
@ -20,14 +17,12 @@ 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)
this.LinkAttempts = this.Database.PlatformLinkAttempts
.Where(l => l.UserId == this.User.UserId)
.OrderByDescending(a => a.Timestamp)
.ToList();

View file

@ -22,7 +22,7 @@
@if (Model.User != null)
{
<p>@Model.Translate(LandingPageStrings.LoggedInAs, Model.User.Username)</p>
if (ServerConfiguration.Instance.Authentication.UseExternalAuth && Model.PendingAuthAttempts > 0)
if (Model.PendingAuthAttempts > 0)
{
<p>
<b><a href="/authentication">@Model.Translate(LandingPageStrings.AuthAttemptsPending, Model.PendingAuthAttempts)</a></b>
@ -30,18 +30,18 @@
}
}
@if (Model.PlayersOnline.Count == 1)
{
<p>@Model.Translate(LandingPageStrings.UsersSingle)</p>
}
else if (Model.PlayersOnline.Count == 0)
@switch (Model.PlayersOnline.Count)
{
case 0:
<p>@Model.Translate(LandingPageStrings.UsersNone)</p>
}
else
{
break;
case 1:
<p>@Model.Translate(LandingPageStrings.UsersSingle)</p>
break;
default:
<p>@Model.Translate(LandingPageStrings.UsersMultiple, Model.PlayersOnline.Count)</p>
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){<span>,</span>} @* whitespace has forced my hand *@
@await user.ToLink(Html, ViewData, language, timeZone, true)
@* whitespace has forced my hand *@
if (i != Model.PlayersOnline.Count)
{
<span>,</span>
}
}
}

View file

@ -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<int> userIds = await this.Database.LastContacts.Where(l => TimeHelper.Timestamp - l.Timestamp < 300).Select(l => l.UserId).ToListAsync();

View file

@ -11,11 +11,8 @@
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderLoginRegister, "/login", "sign in"));
}
else
{
if (ServerConfiguration.Instance.Authentication.UseExternalAuth)
{
Model.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAuthentication, "/authentication", "key"));
}
@if (Model.User.IsAdmin)
{

View file

@ -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

View file

@ -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();

View file

@ -25,30 +25,9 @@ public class RegisterForm : BaseLayout
[SuppressMessage("ReSharper", "SpecifyStringComparison")]
public async Task<IActionResult> 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<IActionResult> 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();
}

View file

@ -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<IActionResult> 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()))

View file

@ -35,7 +35,7 @@ public class MatchTests : LighthouseServerTest<GameServerTestStartup>
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<GameServerTestStartup>
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);

View file

@ -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));

View file

@ -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<TStartup> 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<TStartup> where TStartup : class
return response;
}
public async Task<LoginResult> Authenticate(int number = 0)
public async Task<LoginResult> Authenticate(int number = -1)
{
HttpResponseMessage response = await this.AuthenticateResponse(number);

View file

@ -149,6 +149,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=PCSA/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=PCSD/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=PCSF/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Rpcn/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sublevels/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Swingy/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=thumbsdown/@EntryIndexedValue">True</s:Boolean>

View file

@ -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;
}

View file

@ -153,7 +153,6 @@ internal class LegacyServerSettings
configuration.Authentication = new AuthenticationConfiguration
{
RegistrationEnabled = this.RegistrationEnabled,
UseExternalAuth = this.UseExternalAuth,
};
configuration.Captcha = new CaptchaConfiguration

View file

@ -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

View file

@ -40,7 +40,6 @@ public class Database : DbContext
public DbSet<LastContact> LastContacts { get; set; }
public DbSet<VisitedLevel> VisitedLevels { get; set; }
public DbSet<RatedLevel> RatedLevels { get; set; }
public DbSet<AuthenticationAttempt> AuthenticationAttempts { get; set; }
public DbSet<Review> Reviews { get; set; }
public DbSet<RatedReview> RatedReviews { get; set; }
public DbSet<DatabaseCategory> CustomCategories { get; set; }
@ -53,6 +52,7 @@ public class Database : DbContext
public DbSet<RegistrationToken> RegistrationTokens { get; set; }
public DbSet<APIKey> APIKeys { get; set; }
public DbSet<Playlist> Playlists { get; set; }
public DbSet<PlatformLinkAttempt> 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<GameToken?> AuthenticateUser(NPTicket npTicket, string userLocation)
public async Task<GameToken?> 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<User?> UserFromMMAuth(string authToken, bool allowUnapproved = false)
private async Task<User?> 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<User?> UserFromGameRequest(HttpRequest request, bool allowUnapproved = false)
public async Task<User?> 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<GameToken?> GameTokenFromRequest(HttpRequest request, bool allowUnapproved = false)
public async Task<GameToken?> 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));

View file

@ -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);

View file

@ -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
{"'", "&apos;"},
};
public static bool IsValidEmail
(string? email) =>
!string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email);
public static void SanitizeStringsInClass(object? instance)
{
if (instance == null) return;

View file

@ -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<ulong>(
name: "LinkedPsnId",
table: "Users",
type: "bigint unsigned",
nullable: false,
defaultValue: 0ul);
migrationBuilder.AddColumn<ulong>(
name: "LinkedRpcnId",
table: "Users",
type: "bigint unsigned",
nullable: false,
defaultValue: 0ul);
migrationBuilder.AddColumn<string>(
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<string>(
name: "ApprovedIPAddress",
table: "Users",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<bool>(
name: "Approved",
table: "GameTokens",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Used",
table: "GameTokens",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "AuthenticationAttempts",
columns: table => new
{
AuthenticationAttemptId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
GameTokenId = table.Column<int>(type: "int", nullable: false),
IPAddress = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Platform = table.Column<int>(type: "int", nullable: false),
Timestamp = table.Column<long>(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");
}
}
}

View file

@ -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<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<int>(type: "int", nullable: false),
PlatformId = table.Column<ulong>(type: "bigint unsigned", nullable: false),
Platform = table.Column<int>(type: "int", nullable: false),
Timestamp = table.Column<long>(type: "bigint", nullable: false),
IPAddress = table.Column<string>(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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; } = "";
}

View file

@ -87,15 +87,14 @@ public class User
private int PhotosWithMe()
{
List<int> 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<int> 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;

View file

@ -26,6 +26,7 @@
<PackageReference Include="SharpZipLib" Version="1.4.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="YamlDotNet" Version="12.3.1" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.0.0" />
</ItemGroup>
<ItemGroup>

View file

@ -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<int>("AuthenticationAttemptId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("GameTokenId")
.HasColumnType("int");
b.Property<string>("IPAddress")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("Timestamp")
.HasColumnType("bigint");
b.HasKey("AuthenticationAttemptId");
b.HasIndex("GameTokenId");
b.ToTable("AuthenticationAttempts");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.GameToken", b =>
{
b.Property<int>("TokenId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<bool>("Approved")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
@ -500,8 +472,8 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<bool>("Used")
.HasColumnType("tinyint(1)");
b.Property<string>("TicketHash")
.HasColumnType("longtext");
b.Property<int>("UserId")
.HasColumnType("int");
@ -602,6 +574,34 @@ namespace ProjectLighthouse.Migrations
b.ToTable("PhotoSubjects");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.PlatformLinkAttempt", b =>
{
b.Property<int>("PlatformLinkAttemptId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("IPAddress")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<ulong>("PlatformId")
.HasColumnType("bigint unsigned");
b.Property<long>("Timestamp")
.HasColumnType("bigint");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("PlatformLinkAttemptId");
b.HasIndex("UserId");
b.ToTable("PlatformLinkAttempts");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.PlayerData.Profiles.Comment", b =>
{
b.Property<int>("CommentId")
@ -755,9 +755,6 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("AdminGrantedSlots")
.HasColumnType("int");
b.Property<string>("ApprovedIPAddress")
.HasColumnType("longtext");
b.Property<string>("BannedReason")
.HasColumnType("longtext");
@ -794,6 +791,12 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("LevelVisibility")
.HasColumnType("int");
b.Property<ulong>("LinkedPsnId")
.HasColumnType("bigint unsigned");
b.Property<ulong>("LinkedRpcnId")
.HasColumnType("bigint unsigned");
b.Property<int>("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")

View file

@ -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<byte>();
private byte[] ticketSignature { get; set; } = Array.Empty<byte>();
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<Platform, byte[]> 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;
}
// Function is here for future use incase we ever need to read more from the ticket
private static void Read30Ticket(NPTicket npTicket, TicketReader reader)
npTicket.ticketSignature = ParseSignature(reader.ReadTicketBinary());
return true;
}
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;
}
/// <summary>
@ -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)

View file

@ -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();
}