Initial Commit
This commit is contained in:
commit
cb9f923cd6
7 changed files with 412 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
bin
|
||||
obj
|
170
IdentityApi.cs
Normal file
170
IdentityApi.cs
Normal 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
91
IdentityService.cs
Normal 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
109
LoginIdentity.cs
Normal 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
12
api/LoginResponse.cs
Normal 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
10
api/identity.cs
Normal 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
18
ln.web.identity.csproj
Normal 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>
|
Loading…
Reference in a new issue