Updated MoonCore dependencies. Switched to asp.net core native authentication scheme abstractions. Updated claim usage in frontend

This commit is contained in:
2025-08-20 16:16:31 +02:00
parent 60178dc54b
commit 3cc48fb8f7
42 changed files with 1459 additions and 858 deletions

View File

@@ -1,16 +1,9 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces;
using Moonlight.Shared.Http.Requests.Auth;
using Moonlight.ApiServer.Implementations.LocalAuth;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.ApiServer.Http.Controllers.Auth;
@@ -19,93 +12,87 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
[Route("api/auth")]
public class AuthController : Controller
{
private readonly AppConfiguration Configuration;
private readonly DatabaseRepository<User> UserRepository;
private readonly IOAuth2Provider OAuth2Provider;
private readonly IAuthenticationSchemeProvider SchemeProvider;
public AuthController(
AppConfiguration configuration,
DatabaseRepository<User> userRepository,
IOAuth2Provider oAuth2Provider
)
// Add schemes which should be offered to the client here
private readonly string[] SchemeWhitelist = [LocalAuthConstants.AuthenticationScheme];
public AuthController(IAuthenticationSchemeProvider schemeProvider)
{
UserRepository = userRepository;
OAuth2Provider = oAuth2Provider;
Configuration = configuration;
SchemeProvider = schemeProvider;
}
[AllowAnonymous]
[HttpGet("start")]
public async Task<LoginStartResponse> Start()
[HttpGet]
public async Task<AuthSchemeResponse[]> GetSchemes()
{
var url = await OAuth2Provider.Start();
var schemes = await SchemeProvider.GetAllSchemesAsync();
return new LoginStartResponse()
{
Url = url
};
}
[AllowAnonymous]
[HttpPost("complete")]
public async Task<LoginCompleteResponse> Complete([FromBody] LoginCompleteRequest request)
{
var user = await OAuth2Provider.Complete(request.Code);
if (user == null)
throw new HttpApiException("Unable to load user data", 500);
// Generate token
var securityTokenDescriptor = new SecurityTokenDescriptor()
{
Expires = DateTime.Now.AddHours(Configuration.Authentication.TokenDuration),
IssuedAt = DateTime.Now,
NotBefore = DateTime.Now.AddMinutes(-1),
Claims = new Dictionary<string, object>()
return schemes
.Where(x => SchemeWhitelist.Contains(x.Name))
.Select(scheme => new AuthSchemeResponse()
{
{
"userId",
user.Id
},
{
"permissions",
string.Join(";", user.Permissions)
}
},
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Authentication.Secret)
),
SecurityAlgorithms.HmacSha256
),
Issuer = Configuration.PublicUrl,
Audience = Configuration.PublicUrl
};
DisplayName = scheme.DisplayName ?? scheme.Name,
Identifier = scheme.Name
})
.ToArray();
}
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
[HttpGet("{identifier:alpha}")]
public async Task StartScheme([FromRoute] string identifier)
{
var scheme = await SchemeProvider.GetSchemeAsync(identifier);
var jwt = jwtSecurityTokenHandler.WriteToken(securityToken);
return new()
// The check for the whitelist ensures a user isn't starting an auth flow
// which isn't meant for users
if (scheme == null || !SchemeWhitelist.Contains(scheme.Name))
{
AccessToken = jwt
};
await Results
.Problem(
"Invalid scheme identifier provided",
statusCode: 404
)
.ExecuteAsync(HttpContext);
return;
}
await HttpContext.ChallengeAsync(
scheme.Name,
new AuthenticationProperties()
{
RedirectUri = "/"
}
);
}
[Authorize]
[HttpGet("check")]
public async Task<CheckResponse> Check()
public Task<AuthClaimResponse[]> Check()
{
var userIdStr = User.FindFirstValue("userId")!;
var userId = int.Parse(userIdStr);
var user = await UserRepository.Get().FirstAsync(x => x.Id == userId);
var username = User.FindFirstValue(ClaimTypes.Name)!;
var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var email = User.FindFirstValue(ClaimTypes.Email)!;
var userId = User.FindFirstValue("UserId")!;
var permissions = User.FindFirstValue("Permissions")!;
return new()
var claims = new List<AuthClaimResponse>()
{
Email = user.Email,
Username = user.Username,
Permissions = user.Permissions
new(ClaimTypes.Name, username),
new(ClaimTypes.NameIdentifier, id),
new(ClaimTypes.Email, email),
new("UserId", userId),
new("Permissions", permissions)
};
return Task.FromResult(
claims.ToArray()
);
}
[HttpGet("logout")]
public async Task Logout()
{
await HttpContext.SignOutAsync();
await Results.Redirect("/").ExecuteAsync(HttpContext);
}
}

View File

@@ -0,0 +1,204 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Implementations.LocalAuth;
namespace Moonlight.ApiServer.Http.Controllers.LocalAuth;
[ApiController]
[Route("api/localAuth")]
public class LocalAuthController : Controller
{
private readonly DatabaseRepository<User> UserRepository;
private readonly IServiceProvider ServiceProvider;
private readonly IAuthenticationService AuthenticationService;
private readonly IOptionsMonitor<LocalAuthOptions> Options;
private readonly ILogger<LocalAuthController> Logger;
private readonly AppConfiguration Configuration;
public LocalAuthController(
DatabaseRepository<User> userRepository,
IServiceProvider serviceProvider,
IAuthenticationService authenticationService,
IOptionsMonitor<LocalAuthOptions> options,
ILogger<LocalAuthController> logger,
AppConfiguration configuration
)
{
UserRepository = userRepository;
ServiceProvider = serviceProvider;
AuthenticationService = authenticationService;
Options = options;
Logger = logger;
Configuration = configuration;
}
[HttpGet]
[HttpGet("login")]
public async Task<IResult> Login()
{
var html = await ComponentHelper.RenderComponent<Login>(ServiceProvider);
return Results.Content(html, "text/html");
}
[HttpGet("register")]
public async Task<IResult> Register()
{
var html = await ComponentHelper.RenderComponent<Register>(ServiceProvider);
return Results.Content(html, "text/html");
}
[HttpPost]
[HttpPost("login")]
public async Task<IResult> Login([FromForm] string email, [FromForm] string password)
{
try
{
// Perform login
var user = await InternalLogin(email, password);
// Login user
var options = Options.Get(LocalAuthConstants.AuthenticationScheme);
await AuthenticationService.SignInAsync(HttpContext, options.SignInScheme, new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
],
LocalAuthConstants.AuthenticationScheme
)
), new AuthenticationProperties());
// Redirect back to wasm app
return Results.Redirect("/");
}
catch (Exception e)
{
string errorMessage;
if (e is HttpApiException apiException)
errorMessage = apiException.Title;
else
{
errorMessage = "An internal error occured";
Logger.LogError(e, "An unhandled error occured while logging in user");
}
var html = await ComponentHelper.RenderComponent<Login>(ServiceProvider,
parameters => { parameters["ErrorMessage"] = errorMessage; });
return Results.Content(html, "text/html");
}
}
[HttpPost("register")]
public async Task<IResult> Register([FromForm] string email, [FromForm] string password, [FromForm] string username)
{
try
{
// Perform register
var user = await InternalRegister(username, email, password);
// Login user
var options = Options.Get(LocalAuthConstants.AuthenticationScheme);
await AuthenticationService.SignInAsync(HttpContext, options.SignInScheme, new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
],
LocalAuthConstants.AuthenticationScheme
)
), new AuthenticationProperties());
// Redirect back to wasm app
return Results.Redirect("/");
}
catch (Exception e)
{
string errorMessage;
if (e is HttpApiException apiException)
errorMessage = apiException.Title;
else
{
errorMessage = "An internal error occured";
Logger.LogError(e, "An unhandled error occured while logging in user");
}
var html = await ComponentHelper.RenderComponent<Register>(ServiceProvider,
parameters => { parameters["ErrorMessage"] = errorMessage; });
return Results.Content(html, "text/html");
}
}
private async Task<User> InternalRegister(string username, string email, string password)
{
email = email.ToLower();
username = username.ToLower();
if (await UserRepository.Get().AnyAsync(x => x.Username == username))
throw new HttpApiException("A account with that username already exists", 400);
if (await UserRepository.Get().AnyAsync(x => x.Email == email))
throw new HttpApiException("A account with that email already exists", 400);
string[] permissions = [];
if (Configuration.Authentication.FirstUserAdmin)
{
var count = await UserRepository
.Get()
.CountAsync();
if (count == 0)
permissions = ["*"];
}
var user = new User()
{
Username = username,
Email = email,
Password = HashHelper.Hash(password),
Permissions = permissions
};
var finalUser = await UserRepository.Add(user);
return finalUser;
}
private async Task<User> InternalLogin(string email, string password)
{
email = email.ToLower();
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Email == email);
if (user == null)
throw new HttpApiException("Invalid combination of email and password", 400);
if (!HashHelper.Verify(password, user.Password))
throw new HttpApiException("Invalid combination of email and password", 400);
return user;
}
}

View File

@@ -42,8 +42,7 @@
</form>
<p class="text-base-content/80 mb-4 text-center">
No account?
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=register"
class="link link-animated link-primary font-normal">Create an account</a>
<a href="/api/localAuth/register" class="link link-animated link-primary font-normal">Create an account</a>
</p>
</div>
</div>
@@ -55,8 +54,5 @@
@code
{
[Parameter] public string ClientId { get; set; }
[Parameter] public string RedirectUri { get; set; }
[Parameter] public string ResponseType { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
}

View File

@@ -47,8 +47,7 @@
</form>
<p class="text-base-content/80 mb-4 text-center">
Already registered?
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=login"
class="link link-animated link-primary font-normal">Login into your account</a>
<a href="/api/localAuth/login" class="link link-animated link-primary font-normal">Login into your account</a>
</p>
</div>
</div>
@@ -59,8 +58,5 @@
@code
{
[Parameter] public string ClientId { get; set; }
[Parameter] public string RedirectUri { get; set; }
[Parameter] public string ResponseType { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
}

View File

@@ -1,317 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.Shared.Http.Responses.OAuth2;
namespace Moonlight.ApiServer.Http.Controllers.OAuth2;
[ApiController]
[Route("oauth2")]
public partial class OAuth2Controller : Controller
{
private readonly AppConfiguration Configuration;
private readonly DatabaseRepository<User> UserRepository;
private readonly string ExpectedRedirectUri;
public OAuth2Controller(AppConfiguration configuration, DatabaseRepository<User> userRepository)
{
Configuration = configuration;
UserRepository = userRepository;
ExpectedRedirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
? Configuration.PublicUrl
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
}
[AllowAnonymous]
[HttpGet("authorize")]
public async Task Authorize(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "redirect_uri")] string redirectUri,
[FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "view")] string view = "login"
)
{
if (!Configuration.Authentication.EnableLocalOAuth2)
throw new HttpApiException("Local OAuth2 has been disabled", 403);
if (Configuration.Authentication.OAuth2.ClientId != clientId ||
redirectUri != ExpectedRedirectUri ||
responseType != "code")
{
throw new HttpApiException("Invalid oauth2 request", 400);
}
string html;
if (view == "register")
{
html = await ComponentHelper.RenderComponent<Register>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
});
}
else
{
html = await ComponentHelper.RenderComponent<Login>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
});
}
await Results
.Text(html, "text/html", Encoding.UTF8)
.ExecuteAsync(HttpContext);
}
[AllowAnonymous]
[HttpPost("authorize")]
public async Task AuthorizePost(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "redirect_uri")] string redirectUri,
[FromQuery(Name = "response_type")] string responseType,
[FromForm(Name = "email")] [EmailAddress(ErrorMessage = "You need to provide a valid email address")] string email,
[FromForm(Name = "password")] string password,
[FromForm(Name = "username")] string username = "",
[FromQuery(Name = "view")] string view = "login"
)
{
if (!Configuration.Authentication.EnableLocalOAuth2)
throw new HttpApiException("Local OAuth2 has been disabled", 403);
if (Configuration.Authentication.OAuth2.ClientId != clientId ||
redirectUri != ExpectedRedirectUri ||
responseType != "code")
{
throw new HttpApiException("Invalid oauth2 request", 400);
}
if (view == "register" && string.IsNullOrEmpty(username))
throw new HttpApiException("You need to provide a username", 400);
string? errorMessage = null;
try
{
if (view == "register")
{
var user = await Register(username, email, password);
var code = await GenerateCode(user);
Response.Redirect($"{redirectUri}?code={code}");
}
else
{
var user = await Login(email, password);
var code = await GenerateCode(user);
Response.Redirect($"{redirectUri}?code={code}");
}
}
catch (HttpApiException e)
{
errorMessage = e.Title;
string html;
if (view == "register")
{
html = await ComponentHelper.RenderComponent<Register>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
parameters.Add("ErrorMessage", errorMessage!);
});
}
else
{
html = await ComponentHelper.RenderComponent<Login>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
parameters.Add("ErrorMessage", errorMessage!);
});
}
await Results
.Text(html, "text/html", Encoding.UTF8)
.ExecuteAsync(HttpContext);
}
}
[AllowAnonymous]
[HttpPost("handle")]
public async Task<OAuth2HandleResponse> Handle(
[FromForm(Name = "grant_type")] string grantType,
[FromForm(Name = "code")] string code,
[FromForm(Name = "redirect_uri")] string redirectUri,
[FromForm(Name = "client_id")] string clientId
)
{
if (!Configuration.Authentication.EnableLocalOAuth2)
throw new HttpApiException("Local OAuth2 has been disabled", 403);
// Check header
if (!Request.Headers.ContainsKey("Authorization"))
throw new HttpApiException("You are missing the Authorization header", 400);
var authorizationHeaderValue = Request.Headers["Authorization"].FirstOrDefault() ?? "";
if (authorizationHeaderValue != $"Basic {Configuration.Authentication.OAuth2.ClientSecret}")
throw new HttpApiException("Invalid Authorization header value", 400);
// Check form
if (grantType != "authorization_code")
throw new HttpApiException("Invalid grant type provided", 400);
if (clientId != Configuration.Authentication.OAuth2.ClientId)
throw new HttpApiException("Invalid client id provided", 400);
if (redirectUri != ExpectedRedirectUri)
throw new HttpApiException("Invalid redirect uri provided", 400);
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
ClaimsPrincipal? codeData;
try
{
codeData = jwtSecurityTokenHandler.ValidateToken(code, new TokenValidationParameters()
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
Configuration.Authentication.OAuth2.Secret
)),
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidateAudience = false,
ValidateIssuer = false
}, out _);
}
catch (SecurityTokenException)
{
throw new HttpApiException("Invalid code provided", 400);
}
if (codeData == null)
throw new HttpApiException("Invalid code provided", 400);
var userIdClaim = codeData.Claims.FirstOrDefault(x => x.Type == "id");
if (userIdClaim == null)
throw new HttpApiException("Malformed code provided", 400);
if (!int.TryParse(userIdClaim.Value, out var userId))
throw new HttpApiException("Malformed code provided", 400);
var user = UserRepository
.Get()
.FirstOrDefault(x => x.Id == userId);
if (user == null)
throw new HttpApiException("Malformed code provided", 400);
return new()
{
UserId = user.Id
};
}
private Task<string> GenerateCode(User user)
{
var securityTokenDescriptor = new SecurityTokenDescriptor()
{
Expires = DateTime.Now.AddMinutes(1),
IssuedAt = DateTime.Now,
NotBefore = DateTime.Now.AddMinutes(-1),
Claims = new Dictionary<string, object>()
{
{
"id",
user.Id
}
},
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Authentication.OAuth2.Secret)
),
SecurityAlgorithms.HmacSha256
)
};
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
return Task.FromResult(
jwtSecurityTokenHandler.WriteToken(securityToken)
);
}
private async Task<User> Register(string username, string email, string password)
{
if (await UserRepository.Get().AnyAsync(x => x.Username == username))
throw new HttpApiException("A account with that username already exists", 400);
if (await UserRepository.Get().AnyAsync(x => x.Email == email))
throw new HttpApiException("A account with that email already exists", 400);
if (!UsernameRegex().IsMatch(username))
throw new HttpApiException("The username is only allowed to be contained out of small characters and numbers", 400);
var user = new User()
{
Username = username,
Email = email,
Password = HashHelper.Hash(password),
};
if (Configuration.Authentication.OAuth2.FirstUserAdmin)
{
var userCount = await UserRepository.Get().CountAsync();
if (userCount == 0)
user.Permissions = ["*"];
}
return await UserRepository.Add(user);
}
private async Task<User> Login(string email, string password)
{
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Email == email);
if (user == null)
throw new HttpApiException("Invalid combination of email and password", 400);
if (!HashHelper.Verify(password, user.Password))
throw new HttpApiException("Invalid combination of email and password", 400);
return user;
}
[GeneratedRegex("^[a-z][a-z0-9]*$")]
private static partial Regex UsernameRegex();
}