Skip to content

Commit

Permalink
Authentication via API (#23)
Browse files Browse the repository at this point in the history
* Backend support for API Authentication

* Stub bcrypt passwords, tell legacy user to set password

* Password reset support

* Add /user/me, fix auth

* Bump Bunkum to v2.1.5

* Bump Bunkum to 2.2.0

* Implement CORS

* Add Content-Type to CORS allowed headers

I'm gonna be dealing with this forever now, aren't I?

* Actual implementation for API logins

* Add JoinDate to users

* Cache level categories

* Use int for TokenType

* Remove outdated comments

* Regex for verifying password reset

* Use Encoding.UTF8 for digest calculation

* Combine reset tokens with normal tokens, token expiry
  • Loading branch information
jvyden authored Mar 20, 2023
1 parent ccdb2dd commit a957b60
Show file tree
Hide file tree
Showing 17 changed files with 420 additions and 32 deletions.
16 changes: 15 additions & 1 deletion Refresh.GameServer/Authentication/GameAuthenticationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ public class GameAuthenticationProvider : IAuthenticationProvider<GameUser>
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);
}
}
15 changes: 14 additions & 1 deletion Refresh.GameServer/Authentication/Token.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
8 changes: 8 additions & 0 deletions Refresh.GameServer/Authentication/TokenType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Refresh.GameServer.Authentication;

public enum TokenType
{
Game = 0,
Api = 1,
PasswordReset = 2,
}
62 changes: 56 additions & 6 deletions Refresh.GameServer/Database/RealmDatabaseContext.Tokens.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using JetBrains.Annotations;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Types.UserData;
Expand All @@ -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(() =>
Expand All @@ -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<Token>().FirstOrDefault(t => t.TokenData == tokenData)?.User;
Token? token = this._realm.All<Token>()
.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)
Expand Down
11 changes: 10 additions & 1 deletion Refresh.GameServer/Database/RealmDatabaseProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<dynamic>? oldLevels = migration.OldRealm.DynamicApi.All("GameLevel");
Expand Down Expand Up @@ -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<Token>();
},
};
}
Expand Down
135 changes: 135 additions & 0 deletions Refresh.GameServer/Endpoints/Api/AuthenticationApiEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
1 change: 1 addition & 0 deletions Refresh.GameServer/Endpoints/Api/LevelApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class LevelApiEndpoints : EndpointGroup

[ApiEndpoint("levels")]
[Authentication(false)]
[ClientCacheResponse(86400 / 2)] // cache for half a day
public IEnumerable<LevelCategory> GetCategories(RequestContext context) => CategoryHandler.Categories;

[ApiEndpoint("level/id/{idStr}")]
Expand Down
3 changes: 3 additions & 0 deletions Refresh.GameServer/Endpoints/Api/UserApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion Refresh.GameServer/Endpoints/ApiEndpointAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
8 changes: 8 additions & 0 deletions Refresh.GameServer/Extensions/MemoryStreamExtensions.cs
Original file line number Diff line number Diff line change
@@ -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));
}
Loading

0 comments on commit a957b60

Please sign in to comment.