diff --git a/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs b/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs index 1e29f93b..13980cc8 100644 --- a/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs +++ b/Refresh.GameServer/Authentication/GameAuthenticationProvider.cs @@ -14,6 +14,20 @@ public class GameAuthenticationProvider : IAuthenticationProvider RealmDatabaseContext database = (RealmDatabaseContext)db; Debug.Assert(database != null); - return database.GetUserFromTokenData(request.Cookies["MM_AUTH"]); + // first try to grab token data from MM_AUTH + string? tokenData = request.Cookies["MM_AUTH"]; + TokenType type = TokenType.Game; + + // if this is null, this must be an API request so grab from authorization + if (tokenData == null) + { + type = TokenType.Api; + tokenData = request.RequestHeaders["Authorization"]; + } + + // if still null, then we dont have a token so bail + if (tokenData == null) return null; + + return database.GetUserFromTokenData(tokenData, type); } } \ No newline at end of file diff --git a/Refresh.GameServer/Authentication/Token.cs b/Refresh.GameServer/Authentication/Token.cs index 8467b93f..9f0ecaca 100644 --- a/Refresh.GameServer/Authentication/Token.cs +++ b/Refresh.GameServer/Authentication/Token.cs @@ -8,13 +8,26 @@ namespace Refresh.GameServer.Authentication; #nullable disable +[JsonObject(MemberSerialization.OptIn)] public class Token : RealmObject { [PrimaryKey] public ObjectId TokenId { get; set; } = ObjectId.GenerateNewId(); // this shouldn't ever be serialized, but just in case let's ignore it - [JsonIgnore] [XmlIgnore] public string TokenData { get; set; } + [XmlIgnore] public string TokenData { get; set; } + // Realm can't store enums, use recommended workaround + // ReSharper disable once InconsistentNaming (can't fix due to conflict with TokenType) + internal int _TokenType { get; set; } + + public TokenType TokenType + { + get => (TokenType)this._TokenType; + set => this._TokenType = (int)value; + } + + public DateTimeOffset ExpiresAt { get; set; } + public GameUser User { get; set; } } \ No newline at end of file diff --git a/Refresh.GameServer/Authentication/TokenType.cs b/Refresh.GameServer/Authentication/TokenType.cs new file mode 100644 index 00000000..a94e1da7 --- /dev/null +++ b/Refresh.GameServer/Authentication/TokenType.cs @@ -0,0 +1,8 @@ +namespace Refresh.GameServer.Authentication; + +public enum TokenType +{ + Game = 0, + Api = 1, + PasswordReset = 2, +} \ No newline at end of file diff --git a/Refresh.GameServer/Database/RealmDatabaseContext.Tokens.cs b/Refresh.GameServer/Database/RealmDatabaseContext.Tokens.cs index cc17ada7..62d3284e 100644 --- a/Refresh.GameServer/Database/RealmDatabaseContext.Tokens.cs +++ b/Refresh.GameServer/Database/RealmDatabaseContext.Tokens.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using JetBrains.Annotations; using Refresh.GameServer.Authentication; using Refresh.GameServer.Types.UserData; @@ -6,12 +7,42 @@ namespace Refresh.GameServer.Database; public partial class RealmDatabaseContext { - public Token GenerateTokenForUser(GameUser user) + private const int DefaultCookieLength = 128; + private const int MaxBase64Padding = 4; + private const int MaxGameCookieLength = 127; + private const string GameCookieHeader = "MM_AUTH="; + private static readonly int GameCookieLength; + + private const int DefaultTokenExpirySeconds = 86400; // 1 day + + static RealmDatabaseContext() + { + // LBP cannot store tokens if >127 chars, calculate max possible length here + GameCookieLength = (int)Math.Floor((MaxGameCookieLength - GameCookieHeader.Length - MaxBase64Padding) * 3 / 4.0); + } + + private static string GetTokenString(int length) + { + byte[] tokenData = new byte[length]; + + using RandomNumberGenerator rng = RandomNumberGenerator.Create(); + rng.GetBytes(tokenData); + + return Convert.ToBase64String(tokenData); + } + + public Token GenerateTokenForUser(GameUser user, TokenType type, int? tokenExpirySeconds = null) { + // TODO: JWT (JSON Web Tokens) for TokenType.Api + + int cookieLength = type == TokenType.Game ? GameCookieLength : DefaultCookieLength; + Token token = new() { User = user, - TokenData = Guid.NewGuid().ToString(), + TokenData = GetTokenString(cookieLength), + TokenType = type, + ExpiresAt = DateTimeOffset.Now.AddSeconds(tokenExpirySeconds ?? DefaultTokenExpirySeconds), }; this._realm.Write(() => @@ -23,11 +54,30 @@ public Token GenerateTokenForUser(GameUser user) } [Pure] - [ContractAnnotation("null => null; notnull => canbenull")] - public GameUser? GetUserFromTokenData(string? tokenData) + [ContractAnnotation("=> canbenull")] + public GameUser? GetUserFromTokenData(string tokenData, TokenType type) { - if (tokenData == null) return null; - return this._realm.All().FirstOrDefault(t => t.TokenData == tokenData)?.User; + Token? token = this._realm.All() + .FirstOrDefault(t => t.TokenData == tokenData && t._TokenType == (int)type); + + if (token == null) return null; + + // ReSharper disable once InvertIf + if (token.ExpiresAt < DateTimeOffset.Now) + { + this._realm.Write(() => this._realm.Remove(token)); + return null; + } + + return token.User; + } + + public void SetUserPassword(GameUser user, string passwordBcrypt) + { + this._realm.Write(() => + { + user.PasswordBcrypt = passwordBcrypt; + }); } public bool RevokeTokenByTokenData(string? tokenData) diff --git a/Refresh.GameServer/Database/RealmDatabaseProvider.cs b/Refresh.GameServer/Database/RealmDatabaseProvider.cs index 1f76604c..0bd0b868 100644 --- a/Refresh.GameServer/Database/RealmDatabaseProvider.cs +++ b/Refresh.GameServer/Database/RealmDatabaseProvider.cs @@ -19,7 +19,7 @@ public void Initialize() { this._configuration = new RealmConfiguration(Path.Join(Environment.CurrentDirectory, "refreshGameServer.realm")) { - SchemaVersion = 21, + SchemaVersion = 28, Schema = new[] { typeof(GameUser), @@ -63,6 +63,12 @@ public void Initialize() // In version 13, users were given PlanetsHashes if (oldVersion < 13) newUser.PlanetsHash = "0"; + + // In version 23, users were given bcrypt passwords + if (oldVersion < 23) newUser.PasswordBcrypt = null; + + // In version 26, users were given join dates + if (oldVersion < 26) newUser.JoinDate = 0; } IQueryable? oldLevels = migration.OldRealm.DynamicApi.All("GameLevel"); @@ -94,6 +100,9 @@ public void Initialize() newLevel.UpdateDate = oldLevel.UpdateDate * 1000; } } + + // In version 22, tokens added expiry and types so just wipe them all + if (oldVersion < 22) migration.NewRealm.RemoveAll(); }, }; } diff --git a/Refresh.GameServer/Endpoints/Api/AuthenticationApiEndpoints.cs b/Refresh.GameServer/Endpoints/Api/AuthenticationApiEndpoints.cs new file mode 100644 index 00000000..d41c5771 --- /dev/null +++ b/Refresh.GameServer/Endpoints/Api/AuthenticationApiEndpoints.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bunkum.CustomHttpListener.Parsing; +using Bunkum.HttpServer; +using Bunkum.HttpServer.Endpoints; +using Bunkum.HttpServer.Responses; +using Refresh.GameServer.Authentication; +using Refresh.GameServer.Database; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Endpoints.Api; + +public partial class AuthenticationApiEndpoints : EndpointGroup +{ + // How many rounds to do for password hashing (BCrypt) + // 14 is ~1 second for logins and reset, which is fair because logins are a one-time thing + // 200 OK on POST '/api/v2/resetPassword' (1058ms) + // 200 OK on POST '/api/v2/auth' (1087ms) + // + // If increased, passwords will automatically be rehashed at login time to use the new WorkFactor + // If decreased, passwords will stay at higher WorkFactor until reset + private const int WorkFactor = 14; + + [GeneratedRegex("^[a-f0-9]{128}$")] + private static partial Regex Sha512Regex(); + + [ApiEndpoint("auth", Method.Post)] + [Authentication(false)] + public Response Authenticate(RequestContext context, RealmDatabaseContext database, ApiAuthenticationRequest body) + { + GameUser? user = database.GetUserByUsername(body.Username); + if (user == null) + { + return new Response(new ApiErrorResponse("The username or password was incorrect."), ContentType.Json, HttpStatusCode.Forbidden); + } + + // if this is a legacy user, have them create a password on login + if (user.PasswordBcrypt == null) + { + Token resetToken = database.GenerateTokenForUser(user, TokenType.PasswordReset); + + ApiResetPasswordResponse resetResp = new() + { + Reason = "The account you are trying to sign into is a legacy account. Please set a password.", + ResetToken = resetToken.TokenData, + }; + + return new Response(resetResp, ContentType.Json, HttpStatusCode.Unauthorized); + } + + if (BC.PasswordNeedsRehash(user.PasswordBcrypt, WorkFactor)) + { + database.SetUserPassword(user, BC.HashPassword(body.PasswordSha512, WorkFactor)); + } + + if (!BC.Verify(body.PasswordSha512, user.PasswordBcrypt)) + { + return new Response(new ApiErrorResponse("The username or password was incorrect."), ContentType.Json, HttpStatusCode.Forbidden); + } + + Token token = database.GenerateTokenForUser(user, TokenType.Api); + + ApiAuthenticationResponse resp = new() + { + TokenData = token.TokenData, + UserId = user.UserId.ToString(), + ExpiresAt = token.ExpiresAt, + }; + + return new Response(resp, ContentType.Json); + } + + [ApiEndpoint("resetPassword", Method.Post)] + [Authentication(false)] + public Response ResetPassword(RequestContext context, RealmDatabaseContext database, ApiResetPasswordRequest body) + { + GameUser? user = database.GetUserFromTokenData(body.ResetToken, TokenType.PasswordReset); + if (user == null) return new Response(HttpStatusCode.Unauthorized); + + if (body.PasswordSha512.Length != 128 || !Sha512Regex().IsMatch(body.PasswordSha512)) + return new Response("Password is definitely not SHA512. Please hash the password - it'll work out better for both of us.", + ContentType.Plaintext, HttpStatusCode.BadRequest); + + string? passwordBcrypt = BC.HashPassword(body.PasswordSha512, WorkFactor); + if (passwordBcrypt == null) return new Response(HttpStatusCode.InternalServerError); + + database.SetUserPassword(user, passwordBcrypt); + + return new Response(HttpStatusCode.OK); + } +} + +#nullable disable + +[Serializable] +public class ApiAuthenticationRequest +{ + public string Username { get; set; } + public string PasswordSha512 { get; set; } +} + +[Serializable] +public class ApiAuthenticationResponse +{ + public string TokenData { get; set; } + public string UserId { get; set; } + public DateTimeOffset ExpiresAt { get; set; } +} + +[Serializable] +public class ApiResetPasswordRequest +{ + public string PasswordSha512 { get; set; } + public string ResetToken { get; set; } +} + +[Serializable] +public class ApiResetPasswordResponse +{ + public string Reason { get; set; } + public string ResetToken { get; set; } +} + +[Serializable] +public class ApiErrorResponse +{ + public ApiErrorResponse() {} + + public ApiErrorResponse(string reason) + { + this.Reason = reason; + } + + public string Reason { get; set; } +} \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Api/LevelApiEndpoints.cs b/Refresh.GameServer/Endpoints/Api/LevelApiEndpoints.cs index 82e518fa..d3b9b157 100644 --- a/Refresh.GameServer/Endpoints/Api/LevelApiEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Api/LevelApiEndpoints.cs @@ -20,6 +20,7 @@ public class LevelApiEndpoints : EndpointGroup [ApiEndpoint("levels")] [Authentication(false)] + [ClientCacheResponse(86400 / 2)] // cache for half a day public IEnumerable GetCategories(RequestContext context) => CategoryHandler.Categories; [ApiEndpoint("level/id/{idStr}")] diff --git a/Refresh.GameServer/Endpoints/Api/UserApiEndpoints.cs b/Refresh.GameServer/Endpoints/Api/UserApiEndpoints.cs index 88296054..c0cc56b2 100644 --- a/Refresh.GameServer/Endpoints/Api/UserApiEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Api/UserApiEndpoints.cs @@ -16,4 +16,7 @@ public class UserApiEndpoints : EndpointGroup [Authentication(false)] public GameUser? GetUserByUuid(RequestContext context, RealmDatabaseContext database, string uuid) => database.GetUserByUuid(uuid); + + [ApiEndpoint("user/me")] + public GameUser GetMyUser(RequestContext context, GameUser user) => user; } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/ApiEndpointAttribute.cs b/Refresh.GameServer/Endpoints/ApiEndpointAttribute.cs index d52f3316..13734bb4 100644 --- a/Refresh.GameServer/Endpoints/ApiEndpointAttribute.cs +++ b/Refresh.GameServer/Endpoints/ApiEndpointAttribute.cs @@ -9,7 +9,7 @@ public class ApiEndpointAttribute : EndpointAttribute { // v2, since maybe we want to add add v1 for backwards compatibility with project lighthouse? // LegacyApiEndpointAttribute for lighthouse api - private const string BaseRoute = "/api/v2/"; + public const string BaseRoute = "/api/v2/"; public ApiEndpointAttribute(string route, Method method = Method.Get, ContentType contentType = ContentType.Json) : base(BaseRoute + route, method, contentType) diff --git a/Refresh.GameServer/Endpoints/Game/Handshake/AuthenticationEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Handshake/AuthenticationEndpoints.cs index 2acabee9..59952acf 100644 --- a/Refresh.GameServer/Endpoints/Game/Handshake/AuthenticationEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Handshake/AuthenticationEndpoints.cs @@ -32,7 +32,7 @@ public class AuthenticationEndpoints : EndpointGroup GameUser? user = database.GetUserByUsername(ticket.Username); user ??= database.CreateUser(ticket.Username); - Token token = database.GenerateTokenForUser(user); + Token token = database.GenerateTokenForUser(user, TokenType.Game, 14400); // 4 hours return new LoginResponse { diff --git a/Refresh.GameServer/Extensions/MemoryStreamExtensions.cs b/Refresh.GameServer/Extensions/MemoryStreamExtensions.cs new file mode 100644 index 00000000..47d05d20 --- /dev/null +++ b/Refresh.GameServer/Extensions/MemoryStreamExtensions.cs @@ -0,0 +1,8 @@ +using System.Text; + +namespace Refresh.GameServer.Extensions; + +internal static class MemoryStreamExtensions +{ + internal static void WriteString(this MemoryStream ms, string str) => ms.Write(Encoding.UTF8.GetBytes(str)); +} \ No newline at end of file diff --git a/Refresh.GameServer/Middlewares/CrossOriginMiddleware.cs b/Refresh.GameServer/Middlewares/CrossOriginMiddleware.cs new file mode 100644 index 00000000..7b35df05 --- /dev/null +++ b/Refresh.GameServer/Middlewares/CrossOriginMiddleware.cs @@ -0,0 +1,44 @@ +using System.Net; +using Bunkum.CustomHttpListener.Parsing; +using Bunkum.CustomHttpListener.Request; +using Bunkum.HttpServer.Database; +using Bunkum.HttpServer.Endpoints.Middlewares; +using Refresh.GameServer.Endpoints; + +namespace Refresh.GameServer.Middlewares; + +public class CrossOriginMiddleware : IMiddleware +{ + private static readonly List AllowedMethods = new(); + + static CrossOriginMiddleware() + { + foreach (Method method in Enum.GetValues()) + { + if(method is Method.Options or Method.Invalid) continue; + AllowedMethods.Add(method.ToString().ToUpperInvariant()); + } + } + + public void HandleRequest(ListenerContext context, Lazy database, Action next) + { + // Allow any origin for API + // Mozilla says this is okay: + // "You can also configure a site to allow any site to access it by using the * wildcard. You should only use this for public APIs." + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSMissingAllowOrigin#what_went_wrong + if (context.Uri.AbsolutePath.StartsWith(ApiEndpointAttribute.BaseRoute)) + { + context.ResponseHeaders.Add("Access-Control-Allow-Origin", "*"); + context.ResponseHeaders.Add("Access-Control-Allow-Headers", "Authorization, Content-Type"); + context.ResponseHeaders.Add("Access-Control-Allow-Methods", string.Join(", ", AllowedMethods)); + + if (context.Method == Method.Options) + { + context.ResponseCode = HttpStatusCode.OK; + return; + } + } + + next(); + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Middlewares/DigestMiddleware.cs b/Refresh.GameServer/Middlewares/DigestMiddleware.cs new file mode 100644 index 00000000..d067f070 --- /dev/null +++ b/Refresh.GameServer/Middlewares/DigestMiddleware.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using Bunkum.CustomHttpListener.Request; +using Bunkum.HttpServer; +using Bunkum.HttpServer.Database; +using Bunkum.HttpServer.Endpoints.Middlewares; +using Refresh.GameServer.Extensions; + +namespace Refresh.GameServer.Middlewares; + +public class DigestMiddleware : IMiddleware +{ + // Should be 19 characters (or less maybe?) + // Length was taken from PS3 and PS4 digest keys + private const string DigestKey = "CustomServerDigest"; + + private string CalculateDigest(string url, Stream body, string auth) + { + using MemoryStream ms = new(); + + if (!url.StartsWith("/lbp/upload/")) + { + // get request body + body.CopyTo(ms); + body.Seek(0, SeekOrigin.Begin); + } + + ms.WriteString(auth); + ms.WriteString(url); + ms.WriteString(DigestKey); + + ms.Position = 0; + using SHA1 sha = SHA1.Create(); + string digestResponse = Convert.ToHexString(sha.ComputeHash(ms)).ToLower(); + + return digestResponse; + } + + // Referenced from Project Lighthouse + // https://github.com/LBPUnion/ProjectLighthouse/blob/d16132f67f82555ef636c0dabab5aabf36f57556/ProjectLighthouse.Servers.GameServer/Middlewares/DigestMiddleware.cs + // https://github.com/LBPUnion/ProjectLighthouse/blob/19ea44e0e2ff5f2ebae8d9dfbaf0f979720bd7d9/ProjectLighthouse/Helpers/CryptoHelper.cs#L35 + private bool VerifyDigestRequest(ListenerContext context) + { + string url = context.Uri.AbsolutePath; + string auth = $"{context.Cookies["MM_AUTH"] ?? string.Empty}"; + + string digestResponse = this.CalculateDigest(url, context.InputStream, auth); + + string digestHeader = !url.StartsWith("/lbp/upload/") ? "X-Digest-A" : "X-Digest-B"; + string clientDigest = context.RequestHeaders[digestHeader] ?? string.Empty; + + context.ResponseHeaders["X-Digest-B"] = digestResponse; + if (clientDigest == digestResponse) return true; + + // this._logger.LogWarning(BunkumContext.Digest, $"Digest failed: {clientDigest} != {digestResponse}"); + return false; + } + + private void SetDigestResponse(ListenerContext context) + { + string url = context.Uri.AbsolutePath; + string auth = $"{context.Cookies["MM_AUTH"] ?? string.Empty}"; + + string digestResponse = this.CalculateDigest(url, context.ResponseStream, auth); + + context.ResponseHeaders["X-Digest-A"] = digestResponse; + } + + public void HandleRequest(ListenerContext context, Lazy database, Action next) + { + this.VerifyDigestRequest(context); + Debug.Assert(context.InputStream.Position == 0); // should be at position 0 before we pass down the pipeline + + next(); + + this.SetDigestResponse(context); + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Middlewares/NotFoundLogMiddleware.cs b/Refresh.GameServer/Middlewares/NotFoundLogMiddleware.cs new file mode 100644 index 00000000..75033a9f --- /dev/null +++ b/Refresh.GameServer/Middlewares/NotFoundLogMiddleware.cs @@ -0,0 +1,30 @@ +using System.Net; +using Bunkum.CustomHttpListener.Request; +using Bunkum.HttpServer.Database; +using Bunkum.HttpServer.Endpoints.Middlewares; + +namespace Refresh.GameServer.Middlewares; + +public class NotFoundLogMiddleware : IMiddleware +{ + private const string EndpointFile = "unimplementedEndpoints.txt"; + private readonly List _unimplementedEndpoints; + + public NotFoundLogMiddleware() + { + this._unimplementedEndpoints = File.ReadAllLines(EndpointFile).ToList(); + } + + public void HandleRequest(ListenerContext context, Lazy database, Action next) + { + next(); // Handle the request so we can get the ResponseCode + + if (context.ResponseCode != HttpStatusCode.NotFound) return; + + if(!File.Exists(EndpointFile)) File.WriteAllText(EndpointFile, string.Empty); + if (this._unimplementedEndpoints.Any(e => e.Split('?')[0] == context.Uri.AbsolutePath)) return; + + this._unimplementedEndpoints.Add(context.Uri.PathAndQuery); + File.WriteAllLines(EndpointFile, this._unimplementedEndpoints); + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Refresh.GameServer.csproj b/Refresh.GameServer/Refresh.GameServer.csproj index 6de6b03c..bb269724 100644 --- a/Refresh.GameServer/Refresh.GameServer.csproj +++ b/Refresh.GameServer/Refresh.GameServer.csproj @@ -13,6 +13,10 @@ TRACE;DEBUG true + + + + true @@ -33,10 +37,11 @@ - + + diff --git a/Refresh.GameServer/Startup.cs b/Refresh.GameServer/Startup.cs index b3ee93c4..eb17a51a 100644 --- a/Refresh.GameServer/Startup.cs +++ b/Refresh.GameServer/Startup.cs @@ -1,9 +1,11 @@ -using System.Reflection; +using System.Diagnostics; +using System.Reflection; using Refresh.GameServer.Authentication; using Refresh.GameServer.Configuration; using Refresh.GameServer.Database; using Bunkum.HttpServer; using Bunkum.HttpServer.Storage; +using Refresh.GameServer.Middlewares; #if DEBUGLOCALBUNKUM Console.WriteLine("Starting Refresh with LOCAL Bunkum!"); @@ -16,7 +18,6 @@ BunkumHttpServer server = new() { AssumeAuthenticationRequired = true, - UseDigestSystem = true, }; using RealmDatabaseProvider databaseProvider = new(); @@ -26,24 +27,10 @@ server.UseDataStore(new FileSystemDataStore()); server.UseJsonConfig("refreshGameServer.json"); -server.DiscoverEndpointsFromAssembly(Assembly.GetExecutingAssembly()); - -#region Log unimplemented endpoints -#if DEBUG - -const string endpointFile = "unimplementedEndpoints.txt"; -if(!File.Exists(endpointFile)) File.WriteAllText(endpointFile, string.Empty); -List unimplementedEndpoints = File.ReadAllLines(endpointFile).ToList(); - -server.NotFound += (_, context) => -{ - if (unimplementedEndpoints.Any(e => e.Split('?')[0] == context.Uri.AbsolutePath)) return; +server.AddMiddleware(); +server.AddMiddleware(); +server.AddMiddleware(); - unimplementedEndpoints.Add(context.Uri.PathAndQuery); - File.WriteAllLines(endpointFile, unimplementedEndpoints); -}; - -#endif -#endregion +server.DiscoverEndpointsFromAssembly(Assembly.GetExecutingAssembly()); await server.StartAndBlockAsync(); \ No newline at end of file diff --git a/Refresh.GameServer/Types/UserData/GameUser.cs b/Refresh.GameServer/Types/UserData/GameUser.cs index 0e2b4437..4eee5221 100644 --- a/Refresh.GameServer/Types/UserData/GameUser.cs +++ b/Refresh.GameServer/Types/UserData/GameUser.cs @@ -16,11 +16,14 @@ public partial class GameUser : RealmObject, IUser, INeedsPreparationBeforeSeria { [PrimaryKey] [Indexed] [XmlIgnore] [JsonProperty] public ObjectId UserId { get; set; } = ObjectId.GenerateNewId(); [Indexed] [Required] [XmlIgnore] [JsonProperty] public string Username { get; set; } = string.Empty; + [Indexed] [XmlIgnore] [JsonIgnore] public string? PasswordBcrypt { get; set; } = null; [XmlIgnore] [JsonProperty] public string IconHash { get; set; } = "0"; [XmlElement("biography")] [JsonProperty] public string Description { get; set; } = ""; [XmlElement("location")] [JsonProperty] public GameLocation Location { get; set; } = GameLocation.Zero; + [XmlIgnore] [JsonProperty] public long JoinDate { get; set; } // unix seconds + [XmlIgnore] public UserPins Pins { get; set; } = new(); #nullable disable