Initial Commit

This commit is contained in:
Harald Wolff 2024-09-30 00:11:01 +02:00
commit cb9f923cd6
7 changed files with 412 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
bin
obj

170
IdentityApi.cs Normal file
View file

@ -0,0 +1,170 @@
using ln.collections;
using ln.http;
using ln.web.identity.api;
using Newtonsoft.Json;
using HttpMethod = ln.http.HttpMethod;
namespace ln.web.identity;
public class IdentityApi : HttpEndpointController
{
private IdentityService _identityService;
private Cache<string, byte[]> _challenges = new Cache<string, byte[]>();
public IdentityApi(HttpRouter httpRouter, IdentityService identityService)
:base(httpRouter)
{
_identityService = identityService;
httpRouter.HttpFilters += httpFilter;
}
private void httpFilter(HttpRequestContext httprequestcontext)
{
var request = httprequestcontext.Request;
if (request.Headers.TryGetValue("Authorization", out string authorizationValue))
{
string[] authSplit = authorizationValue.Split(new char[]{ }, StringSplitOptions.RemoveEmptyEntries);
if ((authSplit.Length == 2) && authSplit[0].Equals("Token"))
{
byte[] token = Convert.FromBase64String(authSplit[1]);
if (_identityService.TryGetAuthenticationTokenIdentity(token, out LoginIdentity loginIdentity))
{
httprequestcontext.AuthenticatedPrincipal = new HttpPrincipal(
loginIdentity.Username,
loginIdentity.Username,
loginIdentity.Roles
);
httprequestcontext.SetParameter("auth.token", authSplit[1]);
}
}
}
}
[Map(HttpMethod.GET, "/v1/users/login$")]
public HttpResponse Login([Query] string username = null)
{
if (username is null)
return HttpResponse.BadRequest();
byte[] salt = LoginIdentity.GenerateRandomBytes(128);
byte[] challenge = LoginIdentity.GenerateRandomBytes(128);
if (_identityService.TryGetLoginIdentity(username, out LoginIdentity loginIdentity))
salt = loginIdentity.Salt;
_challenges.Set(username, challenge);
LoginResponse loginResponse = new LoginResponse()
{
Salt = salt,
Challenge = challenge,
Username = username
};
return HttpResponse.OK().Json(loginResponse);
}
[Map(HttpMethod.GET, "/v1/users/authenticate$")]
public HttpResponse Authenticate(
[Query] string username,
[Query] byte[] response)
{
if (_challenges.TryGet(username, out byte[] challenge))
{
if (_identityService.TryGetLoginIdentity(username, out LoginIdentity loginIdentity))
{
if (loginIdentity.ValidateResponse(loginIdentity.Salt, challenge, response))
{
var authenticationToken = _identityService.CreateAuthenticationToken(loginIdentity);
LoginResponse loginResponse = new LoginResponse()
{
Username = loginIdentity.Username,
Token = authenticationToken.TokenValue,
Roles = loginIdentity.Roles
};
return HttpResponse.OK().Json(loginResponse);
}
}
return HttpResponse.Unauthorized();
}
else
return HttpResponse.NoContent();
}
[Requires]
[Map(HttpMethod.GET, "/v1/users/checkToken$")]
public HttpResponse CheckToken() => HttpResponse.OK().Content("Token Valid");
[Requires]
[Map(HttpMethod.GET, "/v1/users/logout")]
public HttpResponse GetLogout(HttpRequestContext requestContext)
{
byte[] token = Convert.FromBase64String(requestContext.GetParameter("auth.token"));
_identityService.RemoveAuthenticationToken(token);
return HttpResponse.OK();
}
[Requires("manage.users")]
[Map(HttpMethod.GET, "/v1/users/(?<username>[a-zA-Z0-9._].+)$")]
public HttpResponse GetUser([Route] string username)
{
if (!_identityService.TryGetLoginIdentity(username, out LoginIdentity loginIdentity))
return HttpResponse.NotFound().Content($"the username ${username} was not found.");
return HttpResponse.OK().Json((User)loginIdentity);
}
[Requires("manage.users")]
[Map(HttpMethod.PATCH, "/v1/users/(?<username>[a-zA-Z0-9._].+)$")]
public HttpResponse PatchUser([Route] string username, [JsonBody] User userPatch)
{
if (!_identityService.TryGetLoginIdentity(username, out LoginIdentity userIdentity))
return HttpResponse.NotFound().Content($"the username ${username} was not found.");
if (userPatch.UserName is { } userPatchUserName && !userPatchUserName.Equals(userIdentity.Username))
return HttpResponse.BadRequest().Content("usernames can't be changed");
if (userPatch.Roles is not null)
userIdentity.Roles = userPatch.Roles;
if (!_identityService.UpdateLoginIdentity(userIdentity))
return HttpResponse.InternalServerError();
return HttpResponse.OK().Json((User)userIdentity);
}
[Requires]
[Map(HttpMethod.PUT, "/v1/users/(?<username>[a-zA-Z0-9._].+)/password$")]
public HttpResponse PutPassword(HttpPrincipal principal, [Query] string username, [JsonBody] string password)
{
if (!principal.Roles.Contains("manage.users") && (!principal.Username.Equals(username)))
return HttpResponse.Forbidden();
if (!_identityService.TryGetLoginIdentity(username, out LoginIdentity loginIdentity))
return HttpResponse.NotFound().Content($"the username ${username} was not found.");
loginIdentity.SetPassword(password);
if (!_identityService.UpdateLoginIdentity(loginIdentity))
return HttpResponse.InternalServerError();
return HttpResponse.OK();
}
[Requires("manage.users")]
[Map(HttpMethod.GET,"/v1/users$")]
public HttpResponse GetUserList()
{
return HttpResponse.OK().Json(_identityService.GetAllLoginIdentities().Select((loginIdentity)=>new User()
{
UserName = loginIdentity.Username,
Roles = loginIdentity.Roles
}));
}
[Requires("manage.users")]
[Map(HttpMethod.GET,"/v1/roles$")]
public HttpResponse GetRolesList()
{
return HttpResponse.OK().Json(RequiresAttribute.KnownRoles);
}
}

91
IdentityService.cs Normal file
View file

@ -0,0 +1,91 @@
using LiteDB;
using ln.collections;
namespace ln.web.identity;
public class AuthenticationToken
{
public string UserName { get; }
public byte[] TokenValue { get; }
public DateTime ValidUntil { get; }
public AuthenticationToken(string userName)
{
UserName = userName;
TokenValue = LoginIdentity.GenerateRandomBytes(32);
ValidUntil = DateTime.Now + TimeSpan.FromMinutes(10);
}
}
public class IdentityService
{
private LiteDatabase _liteDatabase;
private ILiteCollection<LoginIdentity> _identitiesCollection;
public IdentityService(string storagePath)
{
_liteDatabase = new LiteDatabase(storagePath);
_identitiesCollection = _liteDatabase.GetCollection<LoginIdentity>();
_identitiesCollection.EnsureIndex(identity => identity.Username);
if (!_identitiesCollection.Find((identity => identity.Roles.Contains("manage.users"))).Any())
{
Console.WriteLine($"{0}: no identity found with role 'manage.users'. Creating new default admin.", GetType().Name);
string username = "admin";
for (int n = 0; n < 16; n++)
username += Random.Shared.Next(10).ToString();
byte[] secret = new byte[16];
Random.Shared.NextBytes(secret);
string password = Convert.ToBase64String(secret);
LoginIdentity loginIdentity = new LoginIdentity(username, password){ Roles = new string[]{ "manage.users" } };
if (_identitiesCollection.Insert(loginIdentity) is { })
{
Console.WriteLine($"{GetType().Name}: Username: {username} Password: {password}");
}
else
{
Console.Error.WriteLine($"{GetType().Name}: Could not create admin user!");
}
}
}
public bool TryGetLoginIdentity(string username, out LoginIdentity loginIdentity)
{
loginIdentity = _identitiesCollection.FindById(username);
return loginIdentity is LoginIdentity;
}
public bool CreateLoginIdentity(string username) =>
CreateLoginIdentity(username, out LoginIdentity loginIdentity);
public bool CreateLoginIdentity(string username, out LoginIdentity loginIdentity)
{
loginIdentity = new LoginIdentity(username);
return _identitiesCollection.Insert(loginIdentity) is BsonValue;
}
public bool RemoveLoginIdentity(string username) => _identitiesCollection.Delete(username);
public bool UpdateLoginIdentity(LoginIdentity loginIdentity) => _identitiesCollection.Update(loginIdentity);
public IEnumerable<LoginIdentity> GetAllLoginIdentities() => _identitiesCollection.FindAll();
// AuthTokens
private Cache<string, AuthenticationToken> _authenticationTokens = new Cache<string, AuthenticationToken>(TimeSpan.FromMinutes(10));
public AuthenticationToken CreateAuthenticationToken(LoginIdentity loginIdentity)
{
AuthenticationToken authenticationToken = new AuthenticationToken(loginIdentity.Username);
_authenticationTokens.Add(Convert.ToBase64String(authenticationToken.TokenValue), authenticationToken);
return authenticationToken;
}
public void RemoveAuthenticationToken(byte[] token) => _authenticationTokens.Remove(Convert.ToBase64String(token));
public bool TryGetAuthenticationTokenIdentity(byte[] token, out LoginIdentity loginIdentity)
{
if (_authenticationTokens.TryGet(Convert.ToBase64String(token), out AuthenticationToken authenticationToken))
{
loginIdentity = _identitiesCollection.FindById(authenticationToken.UserName);
return true;
}
loginIdentity = null;
return false;
}
}

109
LoginIdentity.cs Normal file
View file

@ -0,0 +1,109 @@
using System.Security.Cryptography;
using System.Text;
using LiteDB;
namespace ln.web.identity;
public class LoginIdentity
{
[BsonId]
public string Username { get; set; }
public byte[] HashedPassword { get; set; }
public byte[] Salt { get; set; }
public string[] Roles { get; set; } = Array.Empty<string>();
private LoginIdentity(){}
public LoginIdentity(string username, string password)
{
Username = username;
CreatePasswordHash(password);
}
public LoginIdentity(string username)
{
Username = username;
}
public LoginIdentity(string username, byte[] salt, byte[] hashedPassword)
{
Username = username;
Salt = salt;
HashedPassword = hashedPassword;
}
public void AddRole(string role)
{
Roles = Roles.Concat(new[] { role }).ToArray();
}
public void RemoveRole(string role)
{
Roles = Roles.Where(r => !r.Equals(role)).ToArray();
}
public void SetPassword(string password) => CreatePasswordHash(password);
private void CreatePasswordHash(string password)
{
using (var hmac = new HMACSHA512())
{
Salt = hmac.Key;
HashedPassword = hmac.ComputeHash(Encoding.UTF8.GetBytes(password));
}
}
public bool VerifyPassword(string password)
{
using (var hmac = new HMACSHA512(Salt))
{
var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(password));
for (int i = 0; i < computedHash.Length; i++)
{
if (computedHash[i] != HashedPassword[i])
return false;
}
return true;
}
}
public bool ValidateResponse(byte[] salt, byte[] challenge, byte[] response)
{
ArgumentNullException.ThrowIfNull(salt, nameof(salt));
ArgumentNullException.ThrowIfNull(challenge, nameof(challenge));
ArgumentNullException.ThrowIfNull(response, nameof(response));
var combined = new byte[challenge.Length * 2 + HashedPassword.Length];
Buffer.BlockCopy(challenge, 0, combined, 0, challenge.Length);
Buffer.BlockCopy(HashedPassword, 0, combined,challenge.Length, HashedPassword.Length);
Buffer.BlockCopy(challenge, 0, combined, challenge.Length + HashedPassword.Length, challenge.Length);
using (var hmac = new HMACSHA512(salt))
{
var computedHash = hmac.ComputeHash(combined);
if (response.Length != computedHash.Length)
return false;
for (int i = 0; i < computedHash.Length; i++)
{
if (computedHash[i] != response[i])
return false;
}
return true;
}
}
public static byte[] GenerateChallenge() => Encoding.UTF8.GetBytes(Guid.NewGuid().ToString());
private static Random _random = new Random();
public static byte[] GenerateRandomBytes(int length)
{
byte[] randomBytes = new byte[length];
_random.NextBytes(randomBytes);
return randomBytes;
}
public static implicit operator User(LoginIdentity loginIdentity) => new User()
{ UserName = loginIdentity.Username, Roles = loginIdentity.Roles };
}

12
api/LoginResponse.cs Normal file
View file

@ -0,0 +1,12 @@
using Newtonsoft.Json;
namespace ln.web.identity.api;
struct LoginResponse
{
[JsonProperty("username", NullValueHandling = NullValueHandling.Ignore)] public string Username;
[JsonProperty("salt", NullValueHandling = NullValueHandling.Ignore)] public byte[] Salt;
[JsonProperty("challenge", NullValueHandling = NullValueHandling.Ignore)] public byte[] Challenge;
[JsonProperty("token", NullValueHandling = NullValueHandling.Ignore)] public byte[] Token;
[JsonProperty("roles", NullValueHandling = NullValueHandling.Ignore)] public string[] Roles;
}

10
api/identity.cs Normal file
View file

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace ln.web.identity;
public struct User
{
[JsonProperty("username")] public string? UserName;
[JsonProperty("roles")] public string[]? Roles;
}

18
ln.web.identity.csproj Normal file
View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LiteDB" Version="5.0.20" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ln.http\ln.http\ln.http.csproj" />
</ItemGroup>
</Project>