Compare commits

...

3 commits

Author SHA1 Message Date
Daniel Balk 2b85ffd93b implemented register + email verify requests 2023-11-02 19:42:20 +01:00
Daniel Balk c0533d78fb login api | + bugfixes 2023-11-01 22:49:53 +01:00
Daniel Balk 8dc1130d2a added the api client 2023-11-01 19:14:47 +01:00
20 changed files with 754 additions and 2 deletions

View file

@ -7,6 +7,7 @@
<entry key="Common:buildConfiguration" value="Debug" />
<entry key="Common:noBuild" value="false" />
<entry key="Common:outputFolder" value="App/Database/Migrations" />
<entry key="Common:useDefaultConnection" value="true" />
</map>
</option>
</component>

6
Moonlight/.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

1
Moonlight/.idea/.name Normal file
View file

@ -0,0 +1 @@
data.sqlite

6
Moonlight/.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View file

@ -0,0 +1,10 @@
namespace Moonlight.App.Api;
public abstract class AbstractRequest
{
public IServiceProvider ServiceProvider { get; set; }
public ApiUserContext? Context { get; set; }
public abstract void ReadData(RequestDataContext dataContext);
public abstract Task ProcessRequest();
public abstract ResponseDataBuilder CreateResponse(ResponseDataBuilder builder);
}

View file

@ -0,0 +1,63 @@
using System.Net.WebSockets;
using System.Reflection;
namespace Moonlight.App.Api;
public class ApiManagementService
{
public Dictionary<int, Type> Requests;
public List<ApiUserContext> Contexts;
private readonly IServiceProvider ServiceProvider;
public ApiManagementService(IServiceProvider serviceProvider)
{
Requests = new Dictionary<int, Type>();
Contexts = new List<ApiUserContext>();
ServiceProvider = serviceProvider;
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
var types = assembly.ExportedTypes;
foreach (var type in types)
{
var attribute = type.GetCustomAttribute<ApiRequestAttribute>();
if(attribute == null)
continue;
var id = attribute.Id;
Requests[id] = type;
}
}
}
public AbstractRequest GetRequest(int id, ApiUserContext context)
{
var type = Requests[id];
var obj = Activator.CreateInstance(type) as AbstractRequest;
obj!.Context = context;
obj!.ServiceProvider = ServiceProvider.CreateScope().ServiceProvider;
return obj!;
}
public async Task HandleRequest(ApiUserContext context, byte[] data)
{
var rqd = new RequestDataContext(data);
var id = rqd.ReadInt();
var request = GetRequest(id, context);
request.ReadData(rqd);
await request.ProcessRequest();
var rbd = new ResponseDataBuilder();
rbd = request.CreateResponse(rbd);
CancellationToken t = new CancellationToken();
var bytes = rbd.ToBytes();
await context.WebSocket.SendAsync(bytes, WebSocketMessageType.Binary, true, t);
}
}

View file

@ -0,0 +1,11 @@
namespace Moonlight.App.Api;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ApiRequestAttribute : Attribute
{
public int Id { get; set; }
public ApiRequestAttribute(int id)
{
Id = id;
}
}

View file

@ -0,0 +1,15 @@
using System.Net.WebSockets;
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Api;
public class ApiUserContext
{
public ApiUserContext(WebSocket webSocket)
{
WebSocket = webSocket;
}
public User? User { get; set; }
public WebSocket WebSocket { get; }
}

View file

@ -0,0 +1,51 @@
using System.Buffers.Binary;
using System.Text;
namespace Moonlight.App.Api;
public class RequestDataContext
{
private List<byte> Data;
public RequestDataContext(byte[] data)
{
Data = data.ToList();
}
public int ReadInt()
{
var bytes = Data.Take(4).ToList();
Data.RemoveRange(0, 4);
if (BitConverter.IsLittleEndian) // because of java (the app needing the api is written in java/kotlin) we need to use big endian
{
bytes.Reverse();
}
return BitConverter.ToInt32(bytes.ToArray());
}
public byte ReadByte()
{
var b = Data[0];
Data.RemoveAt(0);
return b;
}
public bool ReadBoolean()
{
var b = ReadByte();
return b == 255;
}
public String ReadString()
{
var len = ReadInt();
var bytes = Data.Take(len).ToList();
Data.RemoveRange(0, len);
return Encoding.UTF8.GetString(bytes.ToArray());
}
}

View file

@ -0,0 +1,141 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Exceptions;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Enums;
using Moonlight.App.Repositories;
using Moonlight.App.Services.Utils;
using OtpNet;
namespace Moonlight.App.Api.Requests.Auth;
[ApiRequest(3)]
public class CredentialBasedLoginRequest : AbstractRequest
{
public string Email { get; set; }
public string Password { get; set; }
public string Code { get; set; }
public bool Success { get; set; }
public bool RequireTotp { get; set; }
/// <summary>
/// 0: all fine
/// 1: wrong credentials
/// 2: Totp enabled
/// 3: TotpKey missing
/// 4: wrong totp code
/// </summary>
public int ErrorId { get; set; }
public string Token { get; set; } = "";
public override ResponseDataBuilder CreateResponse(ResponseDataBuilder builder)
{
builder.WriteBoolean(Success);
builder.WriteBoolean(RequireTotp);
builder.WriteInt(ErrorId);
builder.WriteString(Token);
return builder;
}
public override async Task ProcessRequest()
{
var userRepository = ServiceProvider.GetService<Repository<User>>();
var user = userRepository
.Get()
.FirstOrDefault(x => x.Email == Email);
if (user == null)
{
Success = false;
RequireTotp = false;
ErrorId = 1;
Token = "";
return;
}
if (!HashHelper.Verify(Password, user.Password))
{
Success = false;
RequireTotp = false;
ErrorId = 1;
Token = "";
return;
}
var flags = new FlagStorage(user.Flags); // Construct FlagStorage to check for 2fa
if (!flags[UserFlag.TotpEnabled])
{
// No 2fa found on this user so were done here
Success = true;
RequireTotp = false;
ErrorId = 0;
Token = await GenerateToken(user);
Context!.User = user;
return;
}
// If we reach this point, 2fa is enabled so we need to continue validating
if (string.IsNullOrEmpty(Code))
{
// This will show an additional 2fa login field
Success = false;
RequireTotp = true;
ErrorId = 2;
Token = "";
return;
}
if (user.TotpKey == null)
{
// Hopefully we will never fulfill this check ;)
Success = false;
RequireTotp = false;
ErrorId = 3;
Token = "";
return;
throw new DisplayException("2FA key is missing. Please contact the support to fix your account");
}
// Calculate server side code
var totp = new Totp(Base32Encoding.ToBytes(user.TotpKey));
var codeServerSide = totp.ComputeTotp();
if (codeServerSide == Code)
{
Success = true;
RequireTotp = false;
ErrorId = 0;
Token = await GenerateToken(user);
Context!.User = user;
return;
}
Success = false;
RequireTotp = false;
ErrorId = 4;
Token = "";
}
public async Task<string> GenerateToken(User user)
{
var jwtService = ServiceProvider.GetService<JwtService>();
var token = await jwtService.Create(data =>
{
data.Add("userId", user.Id.ToString());
data.Add("issuedAt", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
}, TimeSpan.FromDays(365));
return token;
}
public override void ReadData(RequestDataContext dataContext)
{
Email = dataContext.ReadString();
Password = dataContext.ReadString();
Code = dataContext.ReadString();
}
}

View file

@ -0,0 +1,50 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Models.Enums;
using Moonlight.App.Repositories;
using Moonlight.App.Services;
using Moonlight.App.Services.Users;
namespace Moonlight.App.Api.Requests.Auth;
[ApiRequest(5)]
public class IsEmailVerifiedRequest : AbstractRequest
{
public bool SendMail { get; set; }
public bool MailVerified { get; set; }
public override async Task ProcessRequest()
{
if(Context.User == null)
return;
var userRepository = ServiceProvider.GetRequiredService<Repository<User>>();
Context.User = userRepository.Get().Where(x => x.Id == Context.User.Id).ToArray()[0];
if (SendMail && Context.User != null)
{
var userAuthService = ServiceProvider.GetRequiredService<UserAuthService>();
await userAuthService.SendVerification(Context!.User);
}
var configService = ServiceProvider.GetRequiredService<ConfigService>();
var reqEmailVerify = configService.Get().Security.EnableEmailVerify;
if (Context?.User?.Flags!.Contains(UserFlag.MailVerified.ToString()) ?? false)
MailVerified = true;
else
MailVerified = !reqEmailVerify;
await Task.Delay(200);
}
public override ResponseDataBuilder CreateResponse(ResponseDataBuilder builder)
{
builder.WriteBoolean(MailVerified);
return builder;
}
public override void ReadData(RequestDataContext dataContext)
{
SendMail = dataContext.ReadBoolean();
}
}

View file

@ -0,0 +1,181 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using Moonlight.App.Database.Entities;
using Moonlight.App.Event;
using Moonlight.App.Exceptions;
using Moonlight.App.Extensions;
using Moonlight.App.Helpers;
using Moonlight.App.Repositories;
using Moonlight.App.Services;
using Moonlight.App.Services.Utils;
namespace Moonlight.App.Api.Requests.Auth;
[ApiRequest(4)]
public class RegisterRequest: AbstractRequest
{
[Required(ErrorMessage = "9")]
[EmailAddress(ErrorMessage = "10")]
public string Email { get; set; } = "";
[Required(ErrorMessage = "8")]
[MinLength(7, ErrorMessage = "7")]
[MaxLength(20, ErrorMessage = "7")]
[RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "6")]
public string Username { get; set; } = "";
[Required(ErrorMessage = "4")]
[MinLength(8, ErrorMessage = "5")]
[MaxLength(256, ErrorMessage = "5")]
public string Password { get; set; } = "";
public string PasswordConfirm { get; set; } = "";
public bool Success { get; set; }
public bool RequireEmailVerify { get; set; }
/// <summary>
/// Error Codes:
/// - 0 all successful
/// - 1 email exists
/// - 2 username exists
/// - 3 passwords do not match
/// - 4 password needs to be provided
/// - 5 password needs to be between 8 and 256 characters
/// - 6 Usernames can only contain lowercase characters and numbers
/// - 7 username has to be between 7 and 20 chars
/// - 8 username required
/// - 9 email required
/// - 10 email invalid
/// </summary>
public int ErrorCode { get; set; }
public string Token { get; set; } = "";
public override ResponseDataBuilder CreateResponse(ResponseDataBuilder builder)
{
builder.WriteBoolean(Success);
builder.WriteBoolean(RequireEmailVerify);
builder.WriteInt(ErrorCode);
builder.WriteString(Token);
return builder;
}
public override void ReadData(RequestDataContext dataContext)
{
Email = dataContext.ReadString();
Username = dataContext.ReadString();
Password = dataContext.ReadString();
PasswordConfirm = dataContext.ReadString();
}
public override async Task ProcessRequest()
{
var userRepository = ServiceProvider.GetRequiredService<Repository<User>>();
var configService = ServiceProvider.GetRequiredService<ConfigService>();
var reqEmailVerify = configService.Get().Security.EnableEmailVerify;
// Event though we have form validation i want to
// ensure that at least these basic formatting things are done
Email = Email.ToLower().Trim();
Username = Username.ToLower().Trim();
if (PasswordConfirm != Password)
{
Token = "";
Success = false;
RequireEmailVerify = false;
ErrorCode = 3;
return;
}
if (!IsPropertyValid(this.GetProperty(x => x.Email)!, out var errorCd))
{
Token = "";
Success = false;
RequireEmailVerify = false;
ErrorCode = errorCd;
return;
}
if (!IsPropertyValid(this.GetProperty(x => x.Password)!, out var errorCd1))
{
Token = "";
Success = false;
RequireEmailVerify = false;
ErrorCode = errorCd1;
return;
}
if (!IsPropertyValid(this.GetProperty(x => x.Username)!, out var errorCd2))
{
Token = "";
Success = false;
RequireEmailVerify = false;
ErrorCode = errorCd2;
return;
}
// Prevent duplication or username and/or email
if (userRepository.Get().Any(x => x.Email == Email))
{
Token = "";
Success = false;
RequireEmailVerify = false;
ErrorCode = 1;
return;
}
if (userRepository.Get().Any(x => x.Username == Username))
{
Token = "";
Success = false;
RequireEmailVerify = false;
ErrorCode = 2;
return;
}
var user = new User()
{
Username = Username,
Email = Email,
Password = HashHelper.HashToString(Password)
};
var result = userRepository.Add(user);
await Events.OnUserRegistered.InvokeAsync(result);
Token = await GenerateToken(user);
Success = true;
RequireEmailVerify = reqEmailVerify;
ErrorCode = 0;
}
public async Task<string> GenerateToken(User user)
{
var jwtService = ServiceProvider.GetService<JwtService>();
var token = await jwtService.Create(data =>
{
data.Add("userId", user.Id.ToString());
data.Add("issuedAt", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
}, TimeSpan.FromDays(365));
return token;
}
private bool IsPropertyValid(PropertyInfo property, out int errorCode)
{
var attribs = property.GetCustomAttributes<ValidationAttribute>();
foreach (var a in attribs)
{
if (!a.IsValid(property.GetValue(this)))
{
errorCode = int.Parse(a.ErrorMessage);
return false;
}
}
errorCode = 0;
return true;
}
}

View file

@ -0,0 +1,65 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Repositories;
using Moonlight.App.Services.Utils;
namespace Moonlight.App.Api.Requests.Auth;
[ApiRequest(2)]
public class TokenBasedLoginRequest: AbstractRequest
{
private String Token { get; set; }
private bool Success { get; set; } = false;
public override ResponseDataBuilder CreateResponse(ResponseDataBuilder builder)
{
builder.WriteBoolean(Success);
return builder;
}
public override async Task ProcessRequest()
{
var jwtService = ServiceProvider.GetRequiredService<JwtService>();
var userRepository = ServiceProvider.GetRequiredService<Repository<User>>();
if (string.IsNullOrEmpty(Token))
return;
if (!await jwtService.Validate(Token))
return;
var data = await jwtService.Decode(Token);
if (!data.ContainsKey("userId"))
return;
var userId = int.Parse(data["userId"]);
var user = userRepository
.Get()
.FirstOrDefault(x => x.Id == userId);
if (user == null)
return;
if (!data.ContainsKey("issuedAt"))
return;
var issuedAt = long.Parse(data["issuedAt"]);
var issuedAtDateTime = DateTimeOffset.FromUnixTimeSeconds(issuedAt).DateTime;
// If the valid time is newer then when the token was issued, the token is not longer valid
if (user.TokenValidTimestamp > issuedAtDateTime)
return;
Context!.User = user;
if (Context.User == null) // If the current user is null, stop loading additional data
return;
Success = true;
}
public override void ReadData(RequestDataContext dataContext)
{
Token = dataContext.ReadString();
}
}

View file

@ -0,0 +1,21 @@
namespace Moonlight.App.Api.Requests;
[ApiRequest(1)]
public class PingRequest : AbstractRequest
{
public override void ReadData(RequestDataContext dataContext)
{
var chunk = dataContext.ReadInt();
}
public override ResponseDataBuilder CreateResponse(ResponseDataBuilder builder)
{
builder.WriteInt(10324);
return builder;
}
public override async Task ProcessRequest()
{
}
}

View file

@ -0,0 +1,49 @@
using System.Text;
namespace Moonlight.App.Api;
public class ResponseDataBuilder
{
private List<byte> Data;
public ResponseDataBuilder()
{
Data = new List<byte>();
}
public void WriteInt(int data)
{
var bytes = BitConverter.GetBytes(data);
if (BitConverter.IsLittleEndian) // because of java (the app needing th api is written in java/kotlin) we need to use big endian
{
bytes = bytes.Reverse().ToArray();
}
Data.AddRange(bytes);
}
public void WriteByte(byte data)
{
Data.Add(data);
}
public void WriteBoolean(bool data)
{
WriteByte(data ? (byte)255 : (byte)0);
}
public void WriteString(String data)
{
var bytes = Encoding.UTF8.GetBytes(data);
var len = bytes.Length;
WriteInt(len);
Data.AddRange(bytes);
}
public byte[] ToBytes()
{
return Data.ToArray();
}
}

View file

@ -0,0 +1,17 @@
using System.Linq.Expressions;
using System.Reflection;
namespace Moonlight.App.Extensions;
public static class TypeExtensions
{
public static PropertyInfo? GetProperty<T, TValue>(this T type, Expression<Func<T, TValue>> selector)
where T : class
{
Expression expression = selector.Body;
return expression.NodeType == ExpressionType.MemberAccess
? (PropertyInfo) ((MemberExpression) expression).Member
: null;
}
}

View file

@ -0,0 +1,59 @@
using System.Net.WebSockets;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Moonlight.App.Api;
namespace Moonlight.App.Http.Controllers.Api;
public class WebsocketController : Controller
{
private readonly ApiManagementService ApiManagementService;
public WebsocketController(ApiManagementService apiManagementService)
{
ApiManagementService = apiManagementService;
}
[Route("/api/ws")]
public async Task Get()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
using (var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync())
{
await Echo(webSocket);
}
}
else
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
}
}
public async Task Echo(WebSocket webSocket)
{
var context = new ApiUserContext(webSocket);
ApiManagementService.Contexts.Add(context);
await webSocket.SendAsync(Encoding.UTF8.GetBytes("Hello World"), WebSocketMessageType.Text,
true, CancellationToken.None);
try
{
while (webSocket.State == WebSocketState.Open)
{
var buffer = new byte[1024 * 10];
var data = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
buffer = buffer[..data.Count];
await ApiManagementService.HandleRequest(context, buffer);
}
}
catch (Exception)
{
}
ApiManagementService.Contexts.Remove(context);
}
}

View file

@ -1,5 +1,6 @@
using BlazorTable;
using Moonlight.App.Actions.Dummy;
using Moonlight.App.Api;
using Moonlight.App.Database;
using Moonlight.App.Database.Enums;
using Moonlight.App.Extensions;
@ -81,6 +82,7 @@ builder.Services.AddSingleton<ConfigService>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<BucketService>();
builder.Services.AddSingleton<MailService>();
builder.Services.AddSingleton<ApiManagementService>();
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
@ -100,6 +102,7 @@ var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseWebSockets();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

View file

@ -4,7 +4,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5132",
"applicationUrl": "http://192.168.178.32:5132",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View file

@ -1,2 +1,4 @@
@echo off
sass style.scss ../wwwroot/css/theme.css
sass style.scss ../wwwroot/css/theme.css
pause