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

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

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