Updated MoonCore dependencies. Switched to asp.net core native authentication scheme abstractions. Updated claim usage in frontend
This commit is contained in:
@@ -55,25 +55,17 @@ public record AppConfiguration
|
|||||||
[YamlMember(Description = "The secret token to use for creating jwts and encrypting things. This needs to be at least 32 characters long")]
|
[YamlMember(Description = "The secret token to use for creating jwts and encrypting things. This needs to be at least 32 characters long")]
|
||||||
public string Secret { get; set; } = Formatter.GenerateString(32);
|
public string Secret { get; set; } = Formatter.GenerateString(32);
|
||||||
|
|
||||||
[YamlMember(Description = "The lifespan of generated user tokens in hours")]
|
[YamlMember(Description = "Settings for the user sessions")]
|
||||||
public int TokenDuration { get; set; } = 24 * 10;
|
public SessionsConfig Sessions { get; set; } = new();
|
||||||
|
|
||||||
[YamlMember(Description = "This enables the use of the local oauth2 provider, so moonlight will use itself as an oauth2 provider")]
|
[YamlMember(Description = "This specifies if the first registered/synced user will become an admin automatically")]
|
||||||
public bool EnableLocalOAuth2 { get; set; } = true;
|
|
||||||
public OAuth2Data OAuth2 { get; set; } = new();
|
|
||||||
|
|
||||||
public record OAuth2Data
|
|
||||||
{
|
|
||||||
public string Secret { get; set; } = Formatter.GenerateString(32);
|
|
||||||
public string ClientId { get; set; } = Formatter.GenerateString(8);
|
|
||||||
public string ClientSecret { get; set; } = Formatter.GenerateString(32);
|
|
||||||
public string? AuthorizationEndpoint { get; set; }
|
|
||||||
public string? AccessEndpoint { get; set; }
|
|
||||||
public string? AuthorizationRedirect { get; set; }
|
|
||||||
|
|
||||||
[YamlMember(Description = "This specifies if the first registered user will become an admin automatically. This only works when using local oauth2")]
|
|
||||||
public bool FirstUserAdmin { get; set; } = true;
|
public bool FirstUserAdmin { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SessionsConfig
|
||||||
|
{
|
||||||
|
public string CookieName { get; set; } = "session";
|
||||||
|
public int ExpiresIn { get; set; } = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DevelopmentConfig
|
public record DevelopmentConfig
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.Security.Claims;
|
||||||
using System.Security.Claims;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using System.Text;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Moonlight.ApiServer.Implementations.LocalAuth;
|
||||||
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.Shared.Http.Responses.Auth;
|
using Moonlight.Shared.Http.Responses.Auth;
|
||||||
|
|
||||||
namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
||||||
@@ -19,93 +12,87 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
|||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController : Controller
|
public class AuthController : Controller
|
||||||
{
|
{
|
||||||
private readonly AppConfiguration Configuration;
|
private readonly IAuthenticationSchemeProvider SchemeProvider;
|
||||||
private readonly DatabaseRepository<User> UserRepository;
|
|
||||||
private readonly IOAuth2Provider OAuth2Provider;
|
|
||||||
|
|
||||||
public AuthController(
|
// Add schemes which should be offered to the client here
|
||||||
AppConfiguration configuration,
|
private readonly string[] SchemeWhitelist = [LocalAuthConstants.AuthenticationScheme];
|
||||||
DatabaseRepository<User> userRepository,
|
|
||||||
IOAuth2Provider oAuth2Provider
|
public AuthController(IAuthenticationSchemeProvider schemeProvider)
|
||||||
|
{
|
||||||
|
SchemeProvider = schemeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<AuthSchemeResponse[]> GetSchemes()
|
||||||
|
{
|
||||||
|
var schemes = await SchemeProvider.GetAllSchemesAsync();
|
||||||
|
|
||||||
|
return schemes
|
||||||
|
.Where(x => SchemeWhitelist.Contains(x.Name))
|
||||||
|
.Select(scheme => new AuthSchemeResponse()
|
||||||
|
{
|
||||||
|
DisplayName = scheme.DisplayName ?? scheme.Name,
|
||||||
|
Identifier = scheme.Name
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{identifier:alpha}")]
|
||||||
|
public async Task StartScheme([FromRoute] string identifier)
|
||||||
|
{
|
||||||
|
var scheme = await SchemeProvider.GetSchemeAsync(identifier);
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
await Results
|
||||||
|
.Problem(
|
||||||
|
"Invalid scheme identifier provided",
|
||||||
|
statusCode: 404
|
||||||
)
|
)
|
||||||
{
|
.ExecuteAsync(HttpContext);
|
||||||
UserRepository = userRepository;
|
|
||||||
OAuth2Provider = oAuth2Provider;
|
return;
|
||||||
Configuration = configuration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
await HttpContext.ChallengeAsync(
|
||||||
[HttpGet("start")]
|
scheme.Name,
|
||||||
public async Task<LoginStartResponse> Start()
|
new AuthenticationProperties()
|
||||||
{
|
{
|
||||||
var url = await OAuth2Provider.Start();
|
RedirectUri = "/"
|
||||||
|
|
||||||
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>()
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
};
|
|
||||||
|
|
||||||
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
|
|
||||||
var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
|
|
||||||
|
|
||||||
var jwt = jwtSecurityTokenHandler.WriteToken(securityToken);
|
|
||||||
|
|
||||||
return new()
|
|
||||||
{
|
|
||||||
AccessToken = jwt
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpGet("check")]
|
[HttpGet("check")]
|
||||||
public async Task<CheckResponse> Check()
|
public Task<AuthClaimResponse[]> Check()
|
||||||
{
|
{
|
||||||
var userIdStr = User.FindFirstValue("userId")!;
|
var username = User.FindFirstValue(ClaimTypes.Name)!;
|
||||||
var userId = int.Parse(userIdStr);
|
var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
||||||
var user = await UserRepository.Get().FirstAsync(x => x.Id == userId);
|
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,
|
new(ClaimTypes.Name, username),
|
||||||
Username = user.Username,
|
new(ClaimTypes.NameIdentifier, id),
|
||||||
Permissions = user.Permissions
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,8 +42,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<p class="text-base-content/80 mb-4 text-center">
|
<p class="text-base-content/80 mb-4 text-center">
|
||||||
No account?
|
No account?
|
||||||
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=register"
|
<a href="/api/localAuth/register" class="link link-animated link-primary font-normal">Create an account</a>
|
||||||
class="link link-animated link-primary font-normal">Create an account</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,8 +54,5 @@
|
|||||||
|
|
||||||
@code
|
@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; }
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
}
|
}
|
||||||
@@ -47,8 +47,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<p class="text-base-content/80 mb-4 text-center">
|
<p class="text-base-content/80 mb-4 text-center">
|
||||||
Already registered?
|
Already registered?
|
||||||
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=login"
|
<a href="/api/localAuth/login" class="link link-animated link-primary font-normal">Login into your account</a>
|
||||||
class="link link-animated link-primary font-normal">Login into your account</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,8 +58,5 @@
|
|||||||
|
|
||||||
@code
|
@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; }
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
}
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -34,15 +34,8 @@ public class CoreConfigDiagnoseProvider : IDiagnoseProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.Database.Password = CheckForNullOrEmpty(config.Database.Password);
|
config.Database.Password = CheckForNullOrEmpty(config.Database.Password);
|
||||||
|
|
||||||
config.Authentication.OAuth2.ClientSecret = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientSecret);
|
|
||||||
|
|
||||||
config.Authentication.OAuth2.Secret = CheckForNullOrEmpty(config.Authentication.OAuth2.Secret);
|
|
||||||
|
|
||||||
config.Authentication.Secret = CheckForNullOrEmpty(config.Authentication.Secret);
|
config.Authentication.Secret = CheckForNullOrEmpty(config.Authentication.Secret);
|
||||||
|
|
||||||
config.Authentication.OAuth2.ClientId = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientId);
|
|
||||||
|
|
||||||
await archive.AddText(
|
await archive.AddText(
|
||||||
"core/config.txt",
|
"core/config.txt",
|
||||||
JsonSerializer.Serialize(
|
JsonSerializer.Serialize(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Moonlight.ApiServer.Implementations.LocalAuth;
|
||||||
|
|
||||||
|
public static class LocalAuthConstants
|
||||||
|
{
|
||||||
|
public const string AuthenticationScheme = "LocalAuth";
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Implementations.LocalAuth;
|
||||||
|
|
||||||
|
public class LocalAuthHandler : AuthenticationHandler<LocalAuthOptions>
|
||||||
|
{
|
||||||
|
public LocalAuthHandler(
|
||||||
|
IOptionsMonitor<LocalAuthOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder
|
||||||
|
) : base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(
|
||||||
|
AuthenticateResult.Fail("Local authentication does not directly support AuthenticateAsync")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
await Results
|
||||||
|
.Redirect("/api/localAuth")
|
||||||
|
.ExecuteAsync(Context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Implementations.LocalAuth;
|
||||||
|
|
||||||
|
public class LocalAuthOptions : AuthenticationSchemeOptions
|
||||||
|
{
|
||||||
|
public string? SignInScheme { get; set; }
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using MoonCore.Exceptions;
|
|
||||||
using MoonCore.Extended.Abstractions;
|
|
||||||
using MoonCore.Helpers;
|
|
||||||
using Moonlight.ApiServer.Configuration;
|
|
||||||
using Moonlight.ApiServer.Database.Entities;
|
|
||||||
using Moonlight.ApiServer.Interfaces;
|
|
||||||
using Moonlight.Shared.Http.Responses.OAuth2;
|
|
||||||
|
|
||||||
namespace Moonlight.ApiServer.Implementations;
|
|
||||||
|
|
||||||
public class LocalOAuth2Provider : IOAuth2Provider
|
|
||||||
{
|
|
||||||
private readonly AppConfiguration Configuration;
|
|
||||||
private readonly ILogger<LocalOAuth2Provider> Logger;
|
|
||||||
private readonly DatabaseRepository<User> UserRepository;
|
|
||||||
|
|
||||||
public LocalOAuth2Provider(
|
|
||||||
AppConfiguration configuration,
|
|
||||||
ILogger<LocalOAuth2Provider> logger,
|
|
||||||
DatabaseRepository<User> userRepository
|
|
||||||
)
|
|
||||||
{
|
|
||||||
UserRepository = userRepository;
|
|
||||||
Configuration = configuration;
|
|
||||||
Logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<string> Start()
|
|
||||||
{
|
|
||||||
var redirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
|
|
||||||
? Configuration.PublicUrl
|
|
||||||
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
|
|
||||||
|
|
||||||
var endpoint = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationEndpoint)
|
|
||||||
? Configuration.PublicUrl + "/oauth2/authorize"
|
|
||||||
: Configuration.Authentication.OAuth2.AuthorizationEndpoint;
|
|
||||||
|
|
||||||
var clientId = Configuration.Authentication.OAuth2.ClientId;
|
|
||||||
|
|
||||||
var url = $"{endpoint}" +
|
|
||||||
$"?client_id={clientId}" +
|
|
||||||
$"&redirect_uri={redirectUri}" +
|
|
||||||
$"&response_type=code";
|
|
||||||
|
|
||||||
return Task.FromResult(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User?> Complete(string code)
|
|
||||||
{
|
|
||||||
// Create http client to call the auth provider
|
|
||||||
var httpClient = new HttpClient();
|
|
||||||
using var httpApiClient = new HttpApiClient(httpClient);
|
|
||||||
|
|
||||||
httpClient.DefaultRequestHeaders.Add("Authorization",
|
|
||||||
$"Basic {Configuration.Authentication.OAuth2.ClientSecret}");
|
|
||||||
|
|
||||||
// Build access endpoint
|
|
||||||
var accessEndpoint = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AccessEndpoint)
|
|
||||||
? $"{Configuration.PublicUrl}/oauth2/handle"
|
|
||||||
: Configuration.Authentication.OAuth2.AccessEndpoint;
|
|
||||||
|
|
||||||
// Build redirect uri
|
|
||||||
var redirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
|
|
||||||
? Configuration.PublicUrl
|
|
||||||
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
|
|
||||||
|
|
||||||
// Call the auth provider
|
|
||||||
OAuth2HandleResponse handleData;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
handleData = await httpApiClient.PostJson<OAuth2HandleResponse>(accessEndpoint, new FormUrlEncodedContent(
|
|
||||||
[
|
|
||||||
new KeyValuePair<string, string>("grant_type", "authorization_code"),
|
|
||||||
new KeyValuePair<string, string>("code", code),
|
|
||||||
new KeyValuePair<string, string>("redirect_uri", redirectUri),
|
|
||||||
new KeyValuePair<string, string>("client_id", Configuration.Authentication.OAuth2.ClientId)
|
|
||||||
]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
catch (HttpApiException e)
|
|
||||||
{
|
|
||||||
if (e.Status == 400)
|
|
||||||
Logger.LogTrace("The auth server returned an error: {e}", e);
|
|
||||||
else
|
|
||||||
Logger.LogCritical("The auth server returned an error: {e}", e);
|
|
||||||
|
|
||||||
throw new HttpApiException("Unable to request user data", 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notice: We just look up the user id here
|
|
||||||
// which works as our oauth2 provider is using the same db.
|
|
||||||
// a real oauth2 provider would create a user here
|
|
||||||
|
|
||||||
// Handle the returned data
|
|
||||||
var userId = handleData.UserId;
|
|
||||||
|
|
||||||
var user = await UserRepository
|
|
||||||
.Get()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == userId);
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MoonCore.Extended.Abstractions;
|
|
||||||
using MoonCore.Extended.JwtInvalidation;
|
|
||||||
using Moonlight.ApiServer.Database.Entities;
|
|
||||||
|
|
||||||
namespace Moonlight.ApiServer.Implementations;
|
|
||||||
|
|
||||||
public class UserAuthInvalidation : IJwtInvalidateHandler
|
|
||||||
{
|
|
||||||
private readonly DatabaseRepository<User> UserRepository;
|
|
||||||
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
|
|
||||||
|
|
||||||
public UserAuthInvalidation(
|
|
||||||
DatabaseRepository<User> userRepository,
|
|
||||||
DatabaseRepository<ApiKey> apiKeyRepository
|
|
||||||
)
|
|
||||||
{
|
|
||||||
UserRepository = userRepository;
|
|
||||||
ApiKeyRepository = apiKeyRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Handle(ClaimsPrincipal principal)
|
|
||||||
{
|
|
||||||
var userIdClaim = principal.FindFirstValue("userId");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(userIdClaim))
|
|
||||||
{
|
|
||||||
var userId = int.Parse(userIdClaim);
|
|
||||||
|
|
||||||
var user = await UserRepository
|
|
||||||
.Get()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == userId);
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return true; // User is deleted, invalidate session
|
|
||||||
|
|
||||||
var iatStr = principal.FindFirstValue("iat")!;
|
|
||||||
var iat = DateTimeOffset.FromUnixTimeSeconds(long.Parse(iatStr));
|
|
||||||
|
|
||||||
// If the token has been issued before the token valid time, its expired, and we want to invalidate it
|
|
||||||
return user.TokenValidTimestamp > iat;
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiKeyIdClaim = principal.FindFirstValue("apiKeyId");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(apiKeyIdClaim))
|
|
||||||
{
|
|
||||||
var apiKeyId = int.Parse(apiKeyIdClaim);
|
|
||||||
|
|
||||||
var apiKey = await ApiKeyRepository
|
|
||||||
.Get()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == apiKeyId);
|
|
||||||
|
|
||||||
// If the api key exists, we don't want to invalidate the request.
|
|
||||||
// If it doesn't exist we want to invalidate the request
|
|
||||||
return apiKey == null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Moonlight.ApiServer.Database.Entities;
|
|
||||||
|
|
||||||
namespace Moonlight.ApiServer.Interfaces;
|
|
||||||
|
|
||||||
public interface IOAuth2Provider
|
|
||||||
{
|
|
||||||
public Task<string> Start();
|
|
||||||
|
|
||||||
public Task<User?> Complete(string code);
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
<PackageReference Include="Hangfire.Core" Version="1.8.20"/>
|
<PackageReference Include="Hangfire.Core" Version="1.8.20"/>
|
||||||
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
|
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"/>
|
||||||
<PackageReference Include="MoonCore" Version="1.9.2" />
|
<PackageReference Include="MoonCore" Version="1.9.6"/>
|
||||||
<PackageReference Include="MoonCore.Extended" Version="1.3.5" />
|
<PackageReference Include="MoonCore.Extended" Version="1.3.6"/>
|
||||||
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>
|
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1"/>
|
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1"/>
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
|
||||||
|
|||||||
32
Moonlight.ApiServer/Services/ApiKeyAuthService.cs
Normal file
32
Moonlight.ApiServer/Services/ApiKeyAuthService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoonCore.Extended.Abstractions;
|
||||||
|
using Moonlight.ApiServer.Database.Entities;
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Services;
|
||||||
|
|
||||||
|
public class ApiKeyAuthService
|
||||||
|
{
|
||||||
|
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
|
||||||
|
|
||||||
|
public ApiKeyAuthService(DatabaseRepository<ApiKey> apiKeyRepository)
|
||||||
|
{
|
||||||
|
ApiKeyRepository = apiKeyRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Validate(ClaimsPrincipal? principal)
|
||||||
|
{
|
||||||
|
// Ignore malformed claims principal
|
||||||
|
if (principal is not { Identity.IsAuthenticated: true })
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var apiKeyIdStr = principal.FindFirstValue("ApiKeyId");
|
||||||
|
|
||||||
|
if (!int.TryParse(apiKeyIdStr, out var apiKeyId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return await ApiKeyRepository
|
||||||
|
.Get()
|
||||||
|
.AnyAsync(x => x.Id == apiKeyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,11 +29,11 @@ public class ApiKeyService
|
|||||||
Claims = new Dictionary<string, object>()
|
Claims = new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
{
|
{
|
||||||
"apiKeyId",
|
"ApiKeyId",
|
||||||
apiKey.Id
|
apiKey.Id
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"permissions",
|
"Permissions",
|
||||||
string.Join(";", apiKey.Permissions)
|
string.Join(";", apiKey.Permissions)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
142
Moonlight.ApiServer/Services/UserAuthService.cs
Normal file
142
Moonlight.ApiServer/Services/UserAuthService.cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MoonCore.Extended.Abstractions;
|
||||||
|
using MoonCore.Extended.Helpers;
|
||||||
|
using MoonCore.Helpers;
|
||||||
|
using Moonlight.ApiServer.Configuration;
|
||||||
|
using Moonlight.ApiServer.Database.Entities;
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Services;
|
||||||
|
|
||||||
|
public class UserAuthService
|
||||||
|
{
|
||||||
|
private readonly ILogger<UserAuthService> Logger;
|
||||||
|
private readonly DatabaseRepository<User> UserRepository;
|
||||||
|
private readonly AppConfiguration Configuration;
|
||||||
|
|
||||||
|
private const string UserIdClaim = "UserId";
|
||||||
|
private const string IssuedAtClaim = "IssuedAt";
|
||||||
|
|
||||||
|
public UserAuthService(
|
||||||
|
ILogger<UserAuthService> logger,
|
||||||
|
DatabaseRepository<User> userRepository,
|
||||||
|
AppConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Logger = logger;
|
||||||
|
UserRepository = userRepository;
|
||||||
|
Configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Sync(ClaimsPrincipal? principal)
|
||||||
|
{
|
||||||
|
// Ignore malformed claims principal
|
||||||
|
if (principal is not { Identity.IsAuthenticated: true })
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Search for email and username. We need both to create the user model if required.
|
||||||
|
// We do a ToLower here because external authentication provider might provide case-sensitive data
|
||||||
|
|
||||||
|
var email = principal.FindFirstValue(ClaimTypes.Email)?.ToLower();
|
||||||
|
var username = principal.FindFirstValue(ClaimTypes.Name)?.ToLower();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(username))
|
||||||
|
{
|
||||||
|
Logger.LogWarning(
|
||||||
|
"The authentication scheme {scheme} did not provide claim types: email, name. These are required to sync to user to the database",
|
||||||
|
principal.Identity.AuthenticationType
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you plan to use multiple auth providers it can be a good idea
|
||||||
|
// to use an identifier in the user model which consists of the provider and the NameIdentifier
|
||||||
|
// instead of the email address. For simplicity, we just use the email as the identifier so multiple auth providers
|
||||||
|
// can lead to the same account when the email matches
|
||||||
|
var user = await UserRepository
|
||||||
|
.Get()
|
||||||
|
.FirstOrDefaultAsync(u => u.Email == email);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
string[] permissions = [];
|
||||||
|
|
||||||
|
// Yes I know we handle the first user admin thing in the LocalAuth too,
|
||||||
|
// but this only works fo the local auth. So if a user uses an external auth scheme
|
||||||
|
// like oauth2 discord, the first user admin toggle would do nothing
|
||||||
|
if (Configuration.Authentication.FirstUserAdmin)
|
||||||
|
{
|
||||||
|
var count = await UserRepository
|
||||||
|
.Get()
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
if (count == 0)
|
||||||
|
permissions = ["*"];
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await UserRepository.Add(new User()
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
TokenValidTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||||
|
Username = username,
|
||||||
|
Password = HashHelper.Hash(Formatter.GenerateString(64)),
|
||||||
|
Permissions = permissions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// You can sync other properties here
|
||||||
|
if (user.Username != username)
|
||||||
|
{
|
||||||
|
user.Username = username;
|
||||||
|
await UserRepository.Update(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
principal.Identities.First().AddClaims([
|
||||||
|
new Claim(UserIdClaim, user.Id.ToString()),
|
||||||
|
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
|
||||||
|
new Claim("Permissions", string.Join(';', user.Permissions))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Validate(ClaimsPrincipal? principal)
|
||||||
|
{
|
||||||
|
// Ignore malformed claims principal
|
||||||
|
if (principal is not { Identity.IsAuthenticated: true })
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Validate if the user still exists, and then we want to validate the token issue time
|
||||||
|
// against the invalidation time
|
||||||
|
|
||||||
|
var userIdStr = principal.FindFirstValue(UserIdClaim);
|
||||||
|
|
||||||
|
if (!int.TryParse(userIdStr, out var userId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var user = await UserRepository
|
||||||
|
.Get()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Token time validation
|
||||||
|
var issuedAtStr = principal.FindFirstValue(IssuedAtClaim);
|
||||||
|
|
||||||
|
if (!long.TryParse(issuedAtStr, out var issuedAtUnix))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var issuedAt = DateTimeOffset
|
||||||
|
.FromUnixTimeSeconds(issuedAtUnix)
|
||||||
|
.ToUniversalTime();
|
||||||
|
|
||||||
|
// If the issued at timestamp is greater than the token validation timestamp
|
||||||
|
// everything is fine. If not it means that the token should be invalidated
|
||||||
|
// as it is too old
|
||||||
|
|
||||||
|
return issuedAt > user.TokenValidTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using MoonCore.Extended.JwtInvalidation;
|
|
||||||
using MoonCore.Permissions;
|
using MoonCore.Permissions;
|
||||||
using Moonlight.ApiServer.Implementations;
|
using Moonlight.ApiServer.Implementations.LocalAuth;
|
||||||
using Moonlight.ApiServer.Interfaces;
|
|
||||||
using Moonlight.ApiServer.Services;
|
using Moonlight.ApiServer.Services;
|
||||||
|
|
||||||
namespace Moonlight.ApiServer.Startup;
|
namespace Moonlight.ApiServer.Startup;
|
||||||
@@ -15,8 +16,25 @@ public partial class Startup
|
|||||||
private Task RegisterAuth()
|
private Task RegisterAuth()
|
||||||
{
|
{
|
||||||
WebApplicationBuilder.Services
|
WebApplicationBuilder.Services
|
||||||
.AddAuthentication("coreAuthentication")
|
.AddAuthentication(options => { options.DefaultScheme = "MainScheme"; })
|
||||||
.AddJwtBearer("coreAuthentication", options =>
|
.AddPolicyScheme("MainScheme", null, options =>
|
||||||
|
{
|
||||||
|
// If an api key is specified via the bearer auth header
|
||||||
|
// we want to use the ApiKey scheme for authenticating the request
|
||||||
|
options.ForwardDefaultSelector = context =>
|
||||||
|
{
|
||||||
|
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||||
|
return "Session";
|
||||||
|
|
||||||
|
var auth = authHeader.FirstOrDefault();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer "))
|
||||||
|
return "Session";
|
||||||
|
|
||||||
|
return "ApiKey";
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddJwtBearer("ApiKey", null, options =>
|
||||||
{
|
{
|
||||||
options.TokenValidationParameters = new()
|
options.TokenValidationParameters = new()
|
||||||
{
|
{
|
||||||
@@ -31,22 +49,116 @@ public partial class Startup
|
|||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
ValidIssuer = Configuration.PublicUrl
|
ValidIssuer = Configuration.PublicUrl
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
WebApplicationBuilder.Services.AddJwtBearerInvalidation("coreAuthentication");
|
options.Events = new JwtBearerEvents()
|
||||||
WebApplicationBuilder.Services.AddScoped<IJwtInvalidateHandler, UserAuthInvalidation>();
|
{
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
var apiKeyAuthService = context
|
||||||
|
.HttpContext
|
||||||
|
.RequestServices
|
||||||
|
.GetRequiredService<ApiKeyAuthService>();
|
||||||
|
|
||||||
|
var result = await apiKeyAuthService.Validate(context.Principal);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
context.Fail("API key has been deleted");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddCookie("Session", null, options =>
|
||||||
|
{
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(Configuration.Authentication.Sessions.ExpiresIn);
|
||||||
|
|
||||||
|
options.Cookie = new CookieBuilder()
|
||||||
|
{
|
||||||
|
Name = Configuration.Authentication.Sessions.CookieName,
|
||||||
|
Path = "/",
|
||||||
|
IsEssential = true,
|
||||||
|
SecurePolicy = CookieSecurePolicy.SameAsRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
// As redirects won't work in our spa which uses API calls
|
||||||
|
// we need to customize the responses when certain actions happen
|
||||||
|
options.Events.OnRedirectToLogin = async context =>
|
||||||
|
{
|
||||||
|
await Results.Problem(
|
||||||
|
title: "Unauthenticated",
|
||||||
|
detail: "You need to authenticate yourself to use this endpoint",
|
||||||
|
statusCode: 401
|
||||||
|
)
|
||||||
|
.ExecuteAsync(context.HttpContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Events.OnRedirectToAccessDenied = async context =>
|
||||||
|
{
|
||||||
|
await Results.Problem(
|
||||||
|
title: "Permission denied",
|
||||||
|
detail: "You are missing the required permissions to access this endpoint",
|
||||||
|
statusCode: 403
|
||||||
|
)
|
||||||
|
.ExecuteAsync(context.HttpContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Events.OnSigningIn = async context =>
|
||||||
|
{
|
||||||
|
var userSyncService = context
|
||||||
|
.HttpContext
|
||||||
|
.RequestServices
|
||||||
|
.GetRequiredService<UserAuthService>();
|
||||||
|
|
||||||
|
var result = await userSyncService.Sync(context.Principal);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
context.Principal = new();
|
||||||
|
else
|
||||||
|
context.Properties.IsPersistent = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Events.OnValidatePrincipal = async context =>
|
||||||
|
{
|
||||||
|
var userSyncService = context
|
||||||
|
.HttpContext
|
||||||
|
.RequestServices
|
||||||
|
.GetRequiredService<UserAuthService>();
|
||||||
|
|
||||||
|
var result = await userSyncService.Validate(context.Principal);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
context.RejectPrincipal();
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddScheme<LocalAuthOptions, LocalAuthHandler>(LocalAuthConstants.AuthenticationScheme, "Local Auth", options =>
|
||||||
|
{
|
||||||
|
options.ForwardAuthenticate = "Session";
|
||||||
|
options.ForwardSignIn = "Session";
|
||||||
|
options.ForwardSignOut = "Session";
|
||||||
|
|
||||||
|
options.SignInScheme = "Session";
|
||||||
|
});
|
||||||
|
|
||||||
WebApplicationBuilder.Services.AddAuthorization();
|
WebApplicationBuilder.Services.AddAuthorization();
|
||||||
|
|
||||||
WebApplicationBuilder.Services.AddAuthorizationPermissions(options =>
|
WebApplicationBuilder.Services.AddAuthorizationPermissions(options =>
|
||||||
{
|
{
|
||||||
options.ClaimName = "permissions";
|
options.ClaimName = "Permissions";
|
||||||
options.Prefix = "permissions:";
|
options.Prefix = "permissions:";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add local oauth2 provider if enabled
|
WebApplicationBuilder.Services.AddScoped<UserAuthService>();
|
||||||
if (Configuration.Authentication.EnableLocalOAuth2)
|
WebApplicationBuilder.Services.AddScoped<ApiKeyAuthService>();
|
||||||
WebApplicationBuilder.Services.AddScoped<IOAuth2Provider, LocalOAuth2Provider>();
|
|
||||||
|
// Setup data protection storage within storage folder
|
||||||
|
// so its persists in containers
|
||||||
|
var dpKeyPath = Path.Combine("storage", "dataProtectionKeys");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(dpKeyPath);
|
||||||
|
|
||||||
|
WebApplicationBuilder.Services
|
||||||
|
.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(
|
||||||
|
new DirectoryInfo(dpKeyPath)
|
||||||
|
);
|
||||||
|
|
||||||
WebApplicationBuilder.Services.AddScoped<UserDeletionService>();
|
WebApplicationBuilder.Services.AddScoped<UserDeletionService>();
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public partial class Startup
|
|||||||
{
|
{
|
||||||
{ "Default", "Information" },
|
{ "Default", "Information" },
|
||||||
{ "Microsoft.AspNetCore", "Warning" },
|
{ "Microsoft.AspNetCore", "Warning" },
|
||||||
{ "System.Net.Http.HttpClient", "Warning" }
|
{ "System.Net.Http.HttpClient", "Warning" },
|
||||||
|
{ "Moonlight.ApiServer.Implementations.LocalAuth.LocalAuthHandler", "Warning" }
|
||||||
};
|
};
|
||||||
|
|
||||||
var logLevelsJson = JsonSerializer.Serialize(defaultLogLevels);
|
var logLevelsJson = JsonSerializer.Serialize(defaultLogLevels);
|
||||||
|
|||||||
19
Moonlight.Client/Implementations/LogErrorFilter.cs
Normal file
19
Moonlight.Client/Implementations/LogErrorFilter.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using MoonCore.Blazor.FlyonUi.Exceptions;
|
||||||
|
|
||||||
|
namespace Moonlight.Client.Implementations;
|
||||||
|
|
||||||
|
public class LogErrorFilter : IGlobalErrorFilter
|
||||||
|
{
|
||||||
|
private readonly ILogger<LogErrorFilter> Logger;
|
||||||
|
|
||||||
|
public LogErrorFilter(ILogger<LogErrorFilter> logger)
|
||||||
|
{
|
||||||
|
Logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> HandleException(Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Global error processed");
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using MoonCore.Blazor.FlyonUi.Exceptions;
|
||||||
|
using MoonCore.Exceptions;
|
||||||
|
|
||||||
|
namespace Moonlight.Client.Implementations;
|
||||||
|
|
||||||
|
public class UnauthenticatedErrorFilter : IGlobalErrorFilter
|
||||||
|
{
|
||||||
|
private readonly NavigationManager Navigation;
|
||||||
|
|
||||||
|
public UnauthenticatedErrorFilter(NavigationManager navigation)
|
||||||
|
{
|
||||||
|
Navigation = navigation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> HandleException(Exception ex)
|
||||||
|
{
|
||||||
|
if (ex is not HttpApiException { Status: 401 })
|
||||||
|
return Task.FromResult(false);
|
||||||
|
|
||||||
|
Navigation.NavigateTo("/api/auth/logout", true);
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Blazor-ApexCharts" Version="6.0.0" />
|
<PackageReference Include="Blazor-ApexCharts" Version="6.0.0" />
|
||||||
<PackageReference Include="MoonCore" Version="1.9.2" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
|
||||||
|
<PackageReference Include="MoonCore" Version="1.9.6" />
|
||||||
<PackageReference Include="MoonCore.Blazor" Version="1.3.1" />
|
<PackageReference Include="MoonCore.Blazor" Version="1.3.1" />
|
||||||
<PackageReference Include="MoonCore.Blazor.FlyonUi" Version="1.0.9" />
|
<PackageReference Include="MoonCore.Blazor.FlyonUi" Version="1.1.5" />
|
||||||
|
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="storage\**\*" />
|
<Compile Remove="storage\**\*" />
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Web;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using MoonCore.Blazor.FlyonUi.Auth;
|
|
||||||
using MoonCore.Blazor.Services;
|
|
||||||
using MoonCore.Exceptions;
|
|
||||||
using MoonCore.Helpers;
|
|
||||||
using Moonlight.Shared.Http.Requests.Auth;
|
|
||||||
using Moonlight.Shared.Http.Responses.Auth;
|
|
||||||
|
|
||||||
namespace Moonlight.Client.Services;
|
|
||||||
|
|
||||||
public class RemoteAuthStateManager : AuthenticationStateManager
|
|
||||||
{
|
|
||||||
private readonly NavigationManager NavigationManager;
|
|
||||||
private readonly HttpApiClient HttpApiClient;
|
|
||||||
private readonly LocalStorageService LocalStorageService;
|
|
||||||
private readonly ILogger<RemoteAuthStateManager> Logger;
|
|
||||||
|
|
||||||
public RemoteAuthStateManager(
|
|
||||||
HttpApiClient httpApiClient,
|
|
||||||
LocalStorageService localStorageService,
|
|
||||||
NavigationManager navigationManager,
|
|
||||||
ILogger<RemoteAuthStateManager> logger
|
|
||||||
)
|
|
||||||
{
|
|
||||||
HttpApiClient = httpApiClient;
|
|
||||||
LocalStorageService = localStorageService;
|
|
||||||
NavigationManager = navigationManager;
|
|
||||||
Logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
||||||
=> await LoadAuthState();
|
|
||||||
|
|
||||||
public override async Task HandleLogin()
|
|
||||||
{
|
|
||||||
var uri = new Uri(NavigationManager.Uri);
|
|
||||||
var codeParam = HttpUtility.ParseQueryString(uri.Query).Get("code");
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(codeParam)) // If this is true, we need to log in the user
|
|
||||||
{
|
|
||||||
await StartLogin();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var loginCompleteData = await HttpApiClient.PostJson<LoginCompleteResponse>(
|
|
||||||
"api/auth/complete",
|
|
||||||
new LoginCompleteRequest()
|
|
||||||
{
|
|
||||||
Code = codeParam
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await LocalStorageService.SetString("AccessToken", loginCompleteData.AccessToken);
|
|
||||||
|
|
||||||
NavigationManager.NavigateTo("/");
|
|
||||||
NotifyAuthenticationStateChanged(LoadAuthState());
|
|
||||||
}
|
|
||||||
catch (HttpApiException e)
|
|
||||||
{
|
|
||||||
Logger.LogError("Unable to complete login: {e}", e);
|
|
||||||
|
|
||||||
await StartLogin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task Logout()
|
|
||||||
{
|
|
||||||
if (await LocalStorageService.ContainsKey("AccessToken"))
|
|
||||||
await LocalStorageService.SetString("AccessToken", "");
|
|
||||||
|
|
||||||
NotifyAuthenticationStateChanged(LoadAuthState());
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Utilities
|
|
||||||
|
|
||||||
private async Task StartLogin()
|
|
||||||
{
|
|
||||||
var loginStartData = await HttpApiClient.GetJson<LoginStartResponse>("api/auth/start");
|
|
||||||
|
|
||||||
NavigationManager.NavigateTo(loginStartData.Url, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<AuthenticationState> LoadAuthState()
|
|
||||||
{
|
|
||||||
AuthenticationState newState;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var checkData = await HttpApiClient.GetJson<CheckResponse>("api/auth/check");
|
|
||||||
|
|
||||||
newState = new(new ClaimsPrincipal(
|
|
||||||
new ClaimsIdentity(
|
|
||||||
[
|
|
||||||
new Claim("username", checkData.Username),
|
|
||||||
new Claim("email", checkData.Email),
|
|
||||||
new Claim("permissions", string.Join(";", checkData.Permissions))
|
|
||||||
],
|
|
||||||
"RemoteAuthStateManager"
|
|
||||||
)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
catch (HttpApiException)
|
|
||||||
{
|
|
||||||
newState = new(new ClaimsPrincipal(
|
|
||||||
new ClaimsIdentity()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
45
Moonlight.Client/Services/RemoteAuthStateProvider.cs
Normal file
45
Moonlight.Client/Services/RemoteAuthStateProvider.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using MoonCore.Exceptions;
|
||||||
|
using MoonCore.Helpers;
|
||||||
|
using Moonlight.Shared.Http.Responses.Auth;
|
||||||
|
|
||||||
|
namespace Moonlight.Client.Services;
|
||||||
|
|
||||||
|
public class RemoteAuthStateProvider : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private readonly HttpApiClient ApiClient;
|
||||||
|
|
||||||
|
public RemoteAuthStateProvider(HttpApiClient apiClient)
|
||||||
|
{
|
||||||
|
ApiClient = apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
{
|
||||||
|
ClaimsPrincipal principal;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var claims = await ApiClient.GetJson<AuthClaimResponse[]>(
|
||||||
|
"api/auth/check"
|
||||||
|
);
|
||||||
|
|
||||||
|
principal = new ClaimsPrincipal(
|
||||||
|
new ClaimsIdentity(
|
||||||
|
claims.Select(x => new Claim(x.Type, x.Value)),
|
||||||
|
"RemoteAuthentication"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (HttpApiException e)
|
||||||
|
{
|
||||||
|
if (e.Status != 401 && e.Status != 403)
|
||||||
|
throw;
|
||||||
|
|
||||||
|
principal = new ClaimsPrincipal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthenticationState(principal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MoonCore.Blazor.FlyonUi.Auth;
|
using MoonCore.Blazor.FlyonUi.Exceptions;
|
||||||
using MoonCore.Permissions;
|
using MoonCore.Permissions;
|
||||||
|
using Moonlight.Client.Implementations;
|
||||||
using Moonlight.Client.Services;
|
using Moonlight.Client.Services;
|
||||||
|
|
||||||
namespace Moonlight.Client.Startup;
|
namespace Moonlight.Client.Startup;
|
||||||
@@ -12,11 +14,12 @@ public partial class Startup
|
|||||||
WebAssemblyHostBuilder.Services.AddAuthorizationCore();
|
WebAssemblyHostBuilder.Services.AddAuthorizationCore();
|
||||||
WebAssemblyHostBuilder.Services.AddCascadingAuthenticationState();
|
WebAssemblyHostBuilder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
WebAssemblyHostBuilder.Services.AddAuthenticationStateManager<RemoteAuthStateManager>();
|
WebAssemblyHostBuilder.Services.AddScoped<AuthenticationStateProvider, RemoteAuthStateProvider>();
|
||||||
|
WebAssemblyHostBuilder.Services.AddScoped<IGlobalErrorFilter, UnauthenticatedErrorFilter>();
|
||||||
|
|
||||||
WebAssemblyHostBuilder.Services.AddAuthorizationPermissions(options =>
|
WebAssemblyHostBuilder.Services.AddAuthorizationPermissions(options =>
|
||||||
{
|
{
|
||||||
options.ClaimName = "permissions";
|
options.ClaimName = "Permissions";
|
||||||
options.Prefix = "permissions:";
|
options.Prefix = "permissions:";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,27 +25,11 @@ public partial class Startup
|
|||||||
WebAssemblyHostBuilder.Services.AddScoped(sp =>
|
WebAssemblyHostBuilder.Services.AddScoped(sp =>
|
||||||
{
|
{
|
||||||
var httpClient = sp.GetRequiredService<HttpClient>();
|
var httpClient = sp.GetRequiredService<HttpClient>();
|
||||||
var httpApiClient = new HttpApiClient(httpClient);
|
return new HttpApiClient(httpClient);
|
||||||
|
|
||||||
var localStorageService = sp.GetRequiredService<LocalStorageService>();
|
|
||||||
|
|
||||||
httpApiClient.OnConfigureRequest += async request =>
|
|
||||||
{
|
|
||||||
var accessToken = await localStorageService.GetString("AccessToken");
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(accessToken))
|
|
||||||
return;
|
|
||||||
|
|
||||||
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
|
||||||
};
|
|
||||||
|
|
||||||
return httpApiClient;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
WebAssemblyHostBuilder.Services.AddScoped<WindowService>();
|
|
||||||
WebAssemblyHostBuilder.Services.AddFileManagerOperations();
|
WebAssemblyHostBuilder.Services.AddFileManagerOperations();
|
||||||
WebAssemblyHostBuilder.Services.AddFlyonUiServices();
|
WebAssemblyHostBuilder.Services.AddFlyonUiServices();
|
||||||
WebAssemblyHostBuilder.Services.AddScoped<LocalStorageService>();
|
|
||||||
|
|
||||||
WebAssemblyHostBuilder.Services.AddScoped<ThemeService>();
|
WebAssemblyHostBuilder.Services.AddScoped<ThemeService>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MoonCore.Blazor.FlyonUi.Exceptions;
|
||||||
using MoonCore.Logging;
|
using MoonCore.Logging;
|
||||||
|
using Moonlight.Client.Implementations;
|
||||||
|
|
||||||
namespace Moonlight.Client.Startup;
|
namespace Moonlight.Client.Startup;
|
||||||
|
|
||||||
@@ -7,6 +10,7 @@ public partial class Startup
|
|||||||
private Task SetupLogging()
|
private Task SetupLogging()
|
||||||
{
|
{
|
||||||
var loggerFactory = new LoggerFactory();
|
var loggerFactory = new LoggerFactory();
|
||||||
|
|
||||||
loggerFactory.AddAnsiConsole();
|
loggerFactory.AddAnsiConsole();
|
||||||
|
|
||||||
Logger = loggerFactory.CreateLogger<Startup>();
|
Logger = loggerFactory.CreateLogger<Startup>();
|
||||||
@@ -19,6 +23,8 @@ public partial class Startup
|
|||||||
WebAssemblyHostBuilder.Logging.ClearProviders();
|
WebAssemblyHostBuilder.Logging.ClearProviders();
|
||||||
WebAssemblyHostBuilder.Logging.AddAnsiConsole();
|
WebAssemblyHostBuilder.Logging.AddAnsiConsole();
|
||||||
|
|
||||||
|
WebAssemblyHostBuilder.Services.AddScoped<IGlobalErrorFilter, LogErrorFilter>();
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,8 @@ public partial class Startup
|
|||||||
WebAssemblyHostBuilder = builder;
|
WebAssemblyHostBuilder = builder;
|
||||||
|
|
||||||
await PrintVersion();
|
await PrintVersion();
|
||||||
await SetupLogging();
|
|
||||||
|
|
||||||
|
await SetupLogging();
|
||||||
await LoadConfiguration();
|
await LoadConfiguration();
|
||||||
await InitializePlugins();
|
await InitializePlugins();
|
||||||
|
|
||||||
|
|||||||
534
Moonlight.Client/Styles/MoonCore.Blazor.FlyonUi/mooncore.map
Normal file
534
Moonlight.Client/Styles/MoonCore.Blazor.FlyonUi/mooncore.map
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
!bg-base-100
|
||||||
|
!border-base-content/40
|
||||||
|
!border-none
|
||||||
|
!flex
|
||||||
|
!font-medium
|
||||||
|
!font-semibold
|
||||||
|
!h-2.5
|
||||||
|
!justify-between
|
||||||
|
!me-1.5
|
||||||
|
!ms-auto
|
||||||
|
!px-2.5
|
||||||
|
!py-0.5
|
||||||
|
!rounded-full
|
||||||
|
!rounded-xs
|
||||||
|
!text-sm
|
||||||
|
!w-2.5
|
||||||
|
*:[grid-area:1/1]
|
||||||
|
*:first:rounded-tl-lg
|
||||||
|
*:last:rounded-tr-lg
|
||||||
|
-left-4
|
||||||
|
-ml-4
|
||||||
|
-translate-x-full
|
||||||
|
-translate-y-1/2
|
||||||
|
absolute
|
||||||
|
accordion
|
||||||
|
accordion-bordered
|
||||||
|
accordion-content
|
||||||
|
accordion-toggle
|
||||||
|
active
|
||||||
|
active-tab:bg-primary
|
||||||
|
active-tab:hover:text-primary-content
|
||||||
|
active-tab:text-primary-content
|
||||||
|
advance-select-menu
|
||||||
|
advance-select-option
|
||||||
|
advance-select-tag
|
||||||
|
advance-select-toggle
|
||||||
|
alert
|
||||||
|
alert-error
|
||||||
|
alert-outline
|
||||||
|
alert-soft
|
||||||
|
align-bottom
|
||||||
|
align-middle
|
||||||
|
animate-bounce
|
||||||
|
animate-ping
|
||||||
|
aria-[current='page']:text-bg-soft-primary
|
||||||
|
avatar
|
||||||
|
badge
|
||||||
|
badge-error
|
||||||
|
badge-info
|
||||||
|
badge-outline
|
||||||
|
badge-primary
|
||||||
|
badge-soft
|
||||||
|
badge-success
|
||||||
|
bg-background
|
||||||
|
bg-background/60
|
||||||
|
bg-base-100
|
||||||
|
bg-base-150
|
||||||
|
bg-base-200
|
||||||
|
bg-base-200!
|
||||||
|
bg-base-200/50
|
||||||
|
bg-base-300
|
||||||
|
bg-base-300/45
|
||||||
|
bg-base-300/50
|
||||||
|
bg-base-300/60
|
||||||
|
bg-error
|
||||||
|
bg-info
|
||||||
|
bg-primary
|
||||||
|
bg-primary/5
|
||||||
|
bg-success
|
||||||
|
bg-transparent
|
||||||
|
bg-warning
|
||||||
|
block
|
||||||
|
blur
|
||||||
|
border
|
||||||
|
border-0
|
||||||
|
border-2
|
||||||
|
border-b
|
||||||
|
border-base-content
|
||||||
|
border-base-content/20
|
||||||
|
border-base-content/25
|
||||||
|
border-base-content/40
|
||||||
|
border-base-content/5
|
||||||
|
border-dashed
|
||||||
|
border-t
|
||||||
|
border-transparent
|
||||||
|
bottom-0
|
||||||
|
bottom-full
|
||||||
|
break-words
|
||||||
|
btn
|
||||||
|
btn-accent
|
||||||
|
btn-active
|
||||||
|
btn-circle
|
||||||
|
btn-disabled
|
||||||
|
btn-error
|
||||||
|
btn-info
|
||||||
|
btn-outline
|
||||||
|
btn-primary
|
||||||
|
btn-secondary
|
||||||
|
btn-sm
|
||||||
|
btn-soft
|
||||||
|
btn-square
|
||||||
|
btn-success
|
||||||
|
btn-text
|
||||||
|
btn-warning
|
||||||
|
card
|
||||||
|
card-alert
|
||||||
|
card-body
|
||||||
|
card-border
|
||||||
|
card-footer
|
||||||
|
card-header
|
||||||
|
card-title
|
||||||
|
carousel
|
||||||
|
carousel-body
|
||||||
|
carousel-next
|
||||||
|
carousel-prev
|
||||||
|
carousel-slide
|
||||||
|
chat
|
||||||
|
chat-avatar
|
||||||
|
chat-bubble
|
||||||
|
chat-footer
|
||||||
|
chat-header
|
||||||
|
chat-receiver
|
||||||
|
chat-sender
|
||||||
|
checkbox
|
||||||
|
checkbox-primary
|
||||||
|
checkbox-xs
|
||||||
|
col-span-1
|
||||||
|
collapse
|
||||||
|
combo-box-selected:block
|
||||||
|
combo-box-selected:dropdown-active
|
||||||
|
complete
|
||||||
|
container
|
||||||
|
contents
|
||||||
|
cursor-default
|
||||||
|
cursor-not-allowed
|
||||||
|
cursor-pointer
|
||||||
|
diff
|
||||||
|
disabled
|
||||||
|
divide-base-150/60
|
||||||
|
divide-y
|
||||||
|
divider
|
||||||
|
drop-shadow
|
||||||
|
dropdown
|
||||||
|
dropdown-active
|
||||||
|
dropdown-disabled
|
||||||
|
dropdown-item
|
||||||
|
dropdown-menu
|
||||||
|
dropdown-open:opacity-100
|
||||||
|
dropdown-open:rotate-180
|
||||||
|
dropdown-toggle
|
||||||
|
duration-300
|
||||||
|
duration-500
|
||||||
|
ease-in-out
|
||||||
|
ease-linear
|
||||||
|
end-3
|
||||||
|
file-upload-complete:progress-success
|
||||||
|
fill-base-content
|
||||||
|
fill-black
|
||||||
|
fill-gray-200
|
||||||
|
filter
|
||||||
|
filter-reset
|
||||||
|
fixed
|
||||||
|
flex
|
||||||
|
flex-1
|
||||||
|
flex-col
|
||||||
|
flex-grow
|
||||||
|
flex-nowrap
|
||||||
|
flex-row
|
||||||
|
flex-shrink-0
|
||||||
|
flex-wrap
|
||||||
|
focus-visible:outline-none
|
||||||
|
focus-within:border-primary
|
||||||
|
focus:border-primary
|
||||||
|
focus:outline-1
|
||||||
|
focus:outline-none
|
||||||
|
focus:outline-primary
|
||||||
|
focus:ring-0
|
||||||
|
font-bold
|
||||||
|
font-inter
|
||||||
|
font-medium
|
||||||
|
font-normal
|
||||||
|
font-semibold
|
||||||
|
gap-0.5
|
||||||
|
gap-1
|
||||||
|
gap-1.5
|
||||||
|
gap-2
|
||||||
|
gap-3
|
||||||
|
gap-4
|
||||||
|
gap-5
|
||||||
|
gap-6
|
||||||
|
gap-x-1
|
||||||
|
gap-x-2
|
||||||
|
gap-x-3
|
||||||
|
gap-y-1
|
||||||
|
gap-y-2.5
|
||||||
|
gap-y-3
|
||||||
|
grid
|
||||||
|
grid-cols-1
|
||||||
|
grid-cols-4
|
||||||
|
grid-flow-col
|
||||||
|
grow
|
||||||
|
grow-0
|
||||||
|
h-12
|
||||||
|
h-2
|
||||||
|
h-3
|
||||||
|
h-32
|
||||||
|
h-64
|
||||||
|
h-8
|
||||||
|
h-auto
|
||||||
|
h-full
|
||||||
|
h-screen
|
||||||
|
helper-text
|
||||||
|
hidden
|
||||||
|
hover:bg-primary/5
|
||||||
|
hover:bg-transparent
|
||||||
|
hover:text-base-content
|
||||||
|
hover:text-base-content/60
|
||||||
|
hover:text-primary
|
||||||
|
image-full
|
||||||
|
inline
|
||||||
|
inline-block
|
||||||
|
inline-flex
|
||||||
|
inline-grid
|
||||||
|
input
|
||||||
|
input-floating
|
||||||
|
input-floating-label
|
||||||
|
input-lg
|
||||||
|
input-md
|
||||||
|
input-sm
|
||||||
|
input-xl
|
||||||
|
inset-0
|
||||||
|
inset-y-0
|
||||||
|
inset-y-2
|
||||||
|
invisible
|
||||||
|
is-invalid
|
||||||
|
is-valid
|
||||||
|
isolate
|
||||||
|
italic
|
||||||
|
items-center
|
||||||
|
items-end
|
||||||
|
items-start
|
||||||
|
join
|
||||||
|
join-item
|
||||||
|
justify-between
|
||||||
|
justify-center
|
||||||
|
justify-end
|
||||||
|
justify-start
|
||||||
|
justify-stretch
|
||||||
|
label-text
|
||||||
|
leading-3
|
||||||
|
leading-3.5
|
||||||
|
leading-6
|
||||||
|
leading-none
|
||||||
|
left-0
|
||||||
|
lg:bg-base-100/20
|
||||||
|
lg:flex
|
||||||
|
lg:gap-y-0
|
||||||
|
lg:grid-cols-2
|
||||||
|
lg:hidden
|
||||||
|
lg:justify-end
|
||||||
|
lg:justify-start
|
||||||
|
lg:min-w-0
|
||||||
|
lg:p-10
|
||||||
|
lg:pb-5
|
||||||
|
lg:pl-64
|
||||||
|
lg:pr-3.5
|
||||||
|
lg:pt-5
|
||||||
|
lg:ring-1
|
||||||
|
lg:ring-base-content/10
|
||||||
|
lg:rounded-lg
|
||||||
|
lg:shadow-xs
|
||||||
|
link
|
||||||
|
link-animated
|
||||||
|
link-hover
|
||||||
|
list-disc
|
||||||
|
list-inside
|
||||||
|
list-none
|
||||||
|
loading
|
||||||
|
loading-lg
|
||||||
|
loading-sm
|
||||||
|
loading-spinner
|
||||||
|
loading-xl
|
||||||
|
loading-xs
|
||||||
|
lowercase
|
||||||
|
m-10
|
||||||
|
mask
|
||||||
|
max-h-52
|
||||||
|
max-lg:flex-col
|
||||||
|
max-lg:hidden
|
||||||
|
max-w-7xl
|
||||||
|
max-w-80
|
||||||
|
max-w-full
|
||||||
|
max-w-lg
|
||||||
|
max-w-sm
|
||||||
|
max-w-xl
|
||||||
|
mb-0.5
|
||||||
|
mb-1
|
||||||
|
mb-1.5
|
||||||
|
mb-2
|
||||||
|
mb-2.5
|
||||||
|
mb-3
|
||||||
|
mb-4
|
||||||
|
mb-5
|
||||||
|
md:min-w-md
|
||||||
|
md:table-cell
|
||||||
|
md:text-3xl
|
||||||
|
me-1
|
||||||
|
me-1.5
|
||||||
|
me-2
|
||||||
|
me-2.5
|
||||||
|
me-5
|
||||||
|
menu
|
||||||
|
menu-active
|
||||||
|
menu-disabled
|
||||||
|
menu-dropdown
|
||||||
|
menu-dropdown-show
|
||||||
|
menu-horizontal
|
||||||
|
menu-title
|
||||||
|
min-h-0
|
||||||
|
min-h-svh
|
||||||
|
min-w-0
|
||||||
|
min-w-28
|
||||||
|
min-w-48
|
||||||
|
min-w-60
|
||||||
|
min-w-[100px]
|
||||||
|
min-w-sm
|
||||||
|
ml-3
|
||||||
|
ml-4
|
||||||
|
modal
|
||||||
|
modal-content
|
||||||
|
modal-dialog
|
||||||
|
modal-middle
|
||||||
|
modal-title
|
||||||
|
mr-4
|
||||||
|
ms-0.5
|
||||||
|
ms-1
|
||||||
|
ms-2
|
||||||
|
ms-3
|
||||||
|
ms-auto
|
||||||
|
mt-1
|
||||||
|
mt-1.5
|
||||||
|
mt-10
|
||||||
|
mt-12
|
||||||
|
mt-2
|
||||||
|
mt-2.5
|
||||||
|
mt-3
|
||||||
|
mt-3.5
|
||||||
|
mt-4
|
||||||
|
mt-5
|
||||||
|
mt-8
|
||||||
|
mx-1
|
||||||
|
mx-auto
|
||||||
|
my-3
|
||||||
|
my-auto
|
||||||
|
object-cover
|
||||||
|
opacity-0
|
||||||
|
opacity-100
|
||||||
|
open
|
||||||
|
origin-top-left
|
||||||
|
outline
|
||||||
|
outline-0
|
||||||
|
overflow-hidden
|
||||||
|
overflow-x-auto
|
||||||
|
overflow-y-auto
|
||||||
|
overlay-open:duration-50
|
||||||
|
overlay-open:opacity-100
|
||||||
|
p-0.5
|
||||||
|
p-1
|
||||||
|
p-2
|
||||||
|
p-3
|
||||||
|
p-4
|
||||||
|
p-5
|
||||||
|
p-6
|
||||||
|
p-8
|
||||||
|
pin-input
|
||||||
|
placeholder-base-content/60
|
||||||
|
pointer-events-auto
|
||||||
|
pointer-events-none
|
||||||
|
progress
|
||||||
|
progress-bar
|
||||||
|
progress-indeterminate
|
||||||
|
progress-primary
|
||||||
|
pt-0
|
||||||
|
pt-0.5
|
||||||
|
pt-3
|
||||||
|
px-1.5
|
||||||
|
px-2
|
||||||
|
px-2.5
|
||||||
|
px-3
|
||||||
|
px-4
|
||||||
|
px-5
|
||||||
|
py-0.5
|
||||||
|
py-1.5
|
||||||
|
py-2
|
||||||
|
py-2.5
|
||||||
|
py-6
|
||||||
|
radial-progress
|
||||||
|
radio
|
||||||
|
range
|
||||||
|
relative
|
||||||
|
resize
|
||||||
|
ring-0
|
||||||
|
ring-1
|
||||||
|
ring-white/10
|
||||||
|
rounded-box
|
||||||
|
rounded-field
|
||||||
|
rounded-full
|
||||||
|
rounded-lg
|
||||||
|
rounded-md
|
||||||
|
rounded-t-lg
|
||||||
|
row-active
|
||||||
|
row-hover
|
||||||
|
rtl:!mr-0
|
||||||
|
select
|
||||||
|
select-disabled:opacity-40
|
||||||
|
select-disabled:pointer-events-none
|
||||||
|
select-floating
|
||||||
|
select-floating-label
|
||||||
|
selected
|
||||||
|
selected:select-active
|
||||||
|
shadow-base-300/20
|
||||||
|
shadow-lg
|
||||||
|
shadow-md
|
||||||
|
shadow-xs
|
||||||
|
shrink-0
|
||||||
|
size-10
|
||||||
|
size-4
|
||||||
|
size-5
|
||||||
|
size-8
|
||||||
|
skeleton
|
||||||
|
skeleton-animated
|
||||||
|
sm:auto-cols-max
|
||||||
|
sm:flex
|
||||||
|
sm:items-center
|
||||||
|
sm:items-end
|
||||||
|
sm:justify-between
|
||||||
|
sm:justify-end
|
||||||
|
sm:max-w-2xl
|
||||||
|
sm:max-w-3xl
|
||||||
|
sm:max-w-4xl
|
||||||
|
sm:max-w-5xl
|
||||||
|
sm:max-w-6xl
|
||||||
|
sm:max-w-7xl
|
||||||
|
sm:max-w-lg
|
||||||
|
sm:max-w-md
|
||||||
|
sm:max-w-xl
|
||||||
|
sm:mb-0
|
||||||
|
sm:mt-5
|
||||||
|
sm:mt-6
|
||||||
|
sm:p-6
|
||||||
|
sm:py-2
|
||||||
|
sm:text-sm/5
|
||||||
|
space-x-1
|
||||||
|
space-y-1
|
||||||
|
space-y-4
|
||||||
|
sr-only
|
||||||
|
static
|
||||||
|
status
|
||||||
|
status-error
|
||||||
|
sticky
|
||||||
|
switch
|
||||||
|
tab
|
||||||
|
tab-active
|
||||||
|
table
|
||||||
|
table-pin-cols
|
||||||
|
table-pin-rows
|
||||||
|
tabs
|
||||||
|
tabs-bordered
|
||||||
|
tabs-lg
|
||||||
|
tabs-lifted
|
||||||
|
tabs-md
|
||||||
|
tabs-sm
|
||||||
|
tabs-xl
|
||||||
|
tabs-xs
|
||||||
|
text-2xl
|
||||||
|
text-4xl
|
||||||
|
text-accent
|
||||||
|
text-base
|
||||||
|
text-base-content
|
||||||
|
text-base-content/40
|
||||||
|
text-base-content/50
|
||||||
|
text-base-content/60
|
||||||
|
text-base-content/70
|
||||||
|
text-base-content/80
|
||||||
|
text-base/6
|
||||||
|
text-center
|
||||||
|
text-error
|
||||||
|
text-error-content
|
||||||
|
text-gray-400
|
||||||
|
text-info
|
||||||
|
text-info-content
|
||||||
|
text-left
|
||||||
|
text-lg
|
||||||
|
text-primary
|
||||||
|
text-primary-content
|
||||||
|
text-sm
|
||||||
|
text-sm/5
|
||||||
|
text-success
|
||||||
|
text-success-content
|
||||||
|
text-warning
|
||||||
|
text-warning-content
|
||||||
|
text-xl
|
||||||
|
text-xs
|
||||||
|
text-xs/5
|
||||||
|
textarea
|
||||||
|
textarea-floating
|
||||||
|
textarea-floating-label
|
||||||
|
theme-controller
|
||||||
|
tooltip
|
||||||
|
tooltip-content
|
||||||
|
top-0
|
||||||
|
top-1/2
|
||||||
|
top-full
|
||||||
|
transform
|
||||||
|
transition
|
||||||
|
transition-all
|
||||||
|
transition-opacity
|
||||||
|
translate-x-0
|
||||||
|
truncate
|
||||||
|
underline
|
||||||
|
uppercase
|
||||||
|
validate
|
||||||
|
w-0
|
||||||
|
w-0.5
|
||||||
|
w-12
|
||||||
|
w-4
|
||||||
|
w-56
|
||||||
|
w-64
|
||||||
|
w-fit
|
||||||
|
w-full
|
||||||
|
whitespace-nowrap
|
||||||
|
z-10
|
||||||
|
z-40
|
||||||
|
z-50
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
@using Moonlight.Client.UI.Layouts
|
@using Moonlight.Client.UI.Layouts
|
||||||
@using Moonlight.Client.Services
|
@using Moonlight.Client.Services
|
||||||
|
@using Moonlight.Client.UI.Partials
|
||||||
|
|
||||||
@inject ApplicationAssemblyService ApplicationAssemblyService
|
@inject ApplicationAssemblyService ApplicationAssemblyService
|
||||||
|
|
||||||
<ApplicationRouter DefaultLayout="@typeof(MainLayout)"
|
<ApplicationRouter DefaultLayout="@typeof(MainLayout)"
|
||||||
AppAssembly="@typeof(App).Assembly"
|
AppAssembly="@typeof(App).Assembly"
|
||||||
AdditionalAssemblies="ApplicationAssemblyService.Assemblies" />
|
AdditionalAssemblies="ApplicationAssemblyService.Assemblies">
|
||||||
|
<LoginTemplate>
|
||||||
|
<LoginSelector />
|
||||||
|
</LoginTemplate>
|
||||||
|
</ApplicationRouter>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using System.Security.Claims
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-6">
|
<div class="col-span-12 md:col-span-6">
|
||||||
<div class="font-medium leading-[1.1] tracking-tight">
|
<div class="font-medium leading-[1.1] tracking-tight">
|
||||||
@@ -18,7 +19,6 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var identity = await AuthState;
|
var identity = await AuthState;
|
||||||
var usernameClaim = identity.User.Claims.ToArray().First(x => x.Type == "username");
|
Username = identity.User.FindFirst(ClaimTypes.Name)!.Value;
|
||||||
Username = usernameClaim.Value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using MoonCore.Blazor.FlyonUi.Auth
|
|
||||||
@using Moonlight.Client.Interfaces
|
@using Moonlight.Client.Interfaces
|
||||||
@using Moonlight.Client.Models
|
@using Moonlight.Client.Models
|
||||||
@using Moonlight.Client.UI.Layouts
|
@using Moonlight.Client.UI.Layouts
|
||||||
|
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject AuthenticationStateManager AuthStateManager
|
|
||||||
@inject IEnumerable<ISidebarItemProvider> SidebarItemProviders
|
@inject IEnumerable<ISidebarItemProvider> SidebarItemProviders
|
||||||
@inject IAuthorizationService AuthorizationService
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
@@ -210,8 +208,8 @@
|
|||||||
var authState = await AuthState;
|
var authState = await AuthState;
|
||||||
|
|
||||||
Identity = authState.User;
|
Identity = authState.User;
|
||||||
Username = Identity.Claims.First(x => x.Type == "username").Value;
|
Username = Identity.FindFirst(ClaimTypes.Name)!.Value;
|
||||||
Email = Identity.Claims.First(x => x.Type == "email").Value;
|
Email = Identity.FindFirst(ClaimTypes.Email)!.Value;
|
||||||
|
|
||||||
var sidebarItems = new List<SidebarItem>();
|
var sidebarItems = new List<SidebarItem>();
|
||||||
|
|
||||||
@@ -260,8 +258,9 @@
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Logout()
|
private Task Logout()
|
||||||
{
|
{
|
||||||
await AuthStateManager.Logout();
|
Navigation.NavigateTo("/api/auth/logout", true);
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
87
Moonlight.Client/UI/Partials/LoginSelector.razor
Normal file
87
Moonlight.Client/UI/Partials/LoginSelector.razor
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
@using MoonCore.Helpers
|
||||||
|
@using Moonlight.Shared.Http.Responses.Auth
|
||||||
|
|
||||||
|
@inject HttpApiClient ApiClient
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<div class="flex h-screen justify-center items-center">
|
||||||
|
<div class="sm:max-w-lg">
|
||||||
|
<div class="w-full card card-body text-center">
|
||||||
|
<LazyLoader EnableDefaultSpacing="false" Load="Load">
|
||||||
|
@if (ShowSelection)
|
||||||
|
{
|
||||||
|
<h5 class="card-title mb-2.5">Login to MoonCore</h5>
|
||||||
|
|
||||||
|
<p class="mb-4">Choose a login provider to start using the app</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full mt-5 gap-y-2.5">
|
||||||
|
@foreach (var scheme in AuthSchemes)
|
||||||
|
{
|
||||||
|
var config = Configs.GetValueOrDefault(scheme.Identifier);
|
||||||
|
|
||||||
|
if (config == null) // Ignore all schemes which have no ui configured
|
||||||
|
continue;
|
||||||
|
|
||||||
|
<button @onclick="() => Start(scheme)" class="btn btn-text w-full" style="background-color: @(config.Color)">
|
||||||
|
<img src="@config.IconUrl"
|
||||||
|
alt="scheme icon"
|
||||||
|
class="size-5 object-cover fill-base-content"/>
|
||||||
|
Sign in with @scheme.DisplayName
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class="loading loading-spinner loading-xl"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</LazyLoader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
private AuthSchemeResponse[] AuthSchemes;
|
||||||
|
private Dictionary<string, AuthSchemeConfig> Configs = new();
|
||||||
|
private bool ShowSelection = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Configs["LocalAuth"] = new AuthSchemeConfig()
|
||||||
|
{
|
||||||
|
Color = "#7636e3",
|
||||||
|
IconUrl = "/placeholder.jpg"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Load(LazyLoader arg)
|
||||||
|
{
|
||||||
|
AuthSchemes = await ApiClient.GetJson<AuthSchemeResponse[]>(
|
||||||
|
"api/auth"
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we only have one auth scheme available
|
||||||
|
// we want to auto redirect the user without
|
||||||
|
// showing the selection screen
|
||||||
|
|
||||||
|
if (AuthSchemes.Length == 1)
|
||||||
|
await Start(AuthSchemes[0]);
|
||||||
|
else
|
||||||
|
ShowSelection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Start(AuthSchemeResponse scheme)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/api/auth/{scheme.Identifier}", true);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
record AuthSchemeConfig
|
||||||
|
{
|
||||||
|
public string Color { get; set; }
|
||||||
|
public string IconUrl { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@using MoonCore.Helpers
|
@using MoonCore.Helpers
|
||||||
@using Moonlight.Client.Implementations
|
@using Moonlight.Client.Implementations
|
||||||
@using MoonCore.Blazor.FlyonUi.Files.Manager
|
@using MoonCore.Blazor.FlyonUi.Files.Manager
|
||||||
|
@using MoonCore.Blazor.FlyonUi.Files.Manager.Operations
|
||||||
|
|
||||||
@attribute [Authorize(Policy = "permissions:admin.system.overview")]
|
@attribute [Authorize(Policy = "permissions:admin.system.overview")]
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@
|
|||||||
<NavTabs Index="2" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/>
|
<NavTabs Index="2" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileManager FsAccess="FsAccess" TransferChunkSize="TransferChunkSize" UploadLimit="UploadLimit"/>
|
<FileManager OnConfigure="OnConfigure" FsAccess="FsAccess" TransferChunkSize="TransferChunkSize"
|
||||||
|
UploadLimit="UploadLimit"/>
|
||||||
|
|
||||||
@code
|
@code
|
||||||
{
|
{
|
||||||
@@ -26,4 +28,16 @@
|
|||||||
{
|
{
|
||||||
FsAccess = new SystemFsAccess(ApiClient);
|
FsAccess = new SystemFsAccess(ApiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnConfigure(FileManagerOptions options)
|
||||||
|
{
|
||||||
|
options.AddMultiOperation<DeleteOperation>();
|
||||||
|
options.AddMultiOperation<MoveOperation>();
|
||||||
|
options.AddMultiOperation<DownloadOperation>();
|
||||||
|
|
||||||
|
options.AddSingleOperation<RenameOperation>();
|
||||||
|
|
||||||
|
options.AddToolbarOperation<CreateFileOperation>();
|
||||||
|
options.AddToolbarOperation<CreateFolderOperation>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Moonlight.Shared.Http.Requests.Auth;
|
|
||||||
|
|
||||||
public class LoginCompleteRequest
|
|
||||||
{
|
|
||||||
[Required(ErrorMessage = "You need to provide a code")]
|
|
||||||
public string Code { get; set; }
|
|
||||||
}
|
|
||||||
20
Moonlight.Shared/Http/Responses/Auth/AuthClaimResponse.cs
Normal file
20
Moonlight.Shared/Http/Responses/Auth/AuthClaimResponse.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Moonlight.Shared.Http.Responses.Auth;
|
||||||
|
|
||||||
|
public class AuthClaimResponse
|
||||||
|
{
|
||||||
|
// ReSharper disable once UnusedMember.Global
|
||||||
|
// Its used by the json serializer ^^
|
||||||
|
public AuthClaimResponse()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthClaimResponse(string type, string value)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Type { get; set; }
|
||||||
|
public string Value { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Moonlight.Shared.Http.Responses.Auth;
|
||||||
|
|
||||||
|
public class AuthSchemeResponse
|
||||||
|
{
|
||||||
|
public string DisplayName { get; set; }
|
||||||
|
public string Identifier { get; set; }
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Moonlight.Shared.Http.Responses.Auth;
|
|
||||||
|
|
||||||
public class CheckResponse
|
|
||||||
{
|
|
||||||
public string Username { get; set; }
|
|
||||||
public string Email { get; set; }
|
|
||||||
public string[] Permissions { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Moonlight.Shared.Http.Responses.Auth;
|
|
||||||
|
|
||||||
public class LoginCompleteResponse
|
|
||||||
{
|
|
||||||
public string AccessToken { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Moonlight.Shared.Http.Responses.Auth;
|
|
||||||
|
|
||||||
public class LoginStartResponse
|
|
||||||
{
|
|
||||||
public string Url { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Moonlight.Shared.Http.Responses.OAuth2;
|
|
||||||
|
|
||||||
public class OAuth2HandleResponse
|
|
||||||
{
|
|
||||||
public int UserId { get; set; }
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user