Updated MoonCore dependencies. Switched to asp.net core native authentication scheme abstractions. Updated claim usage in frontend
This commit is contained in:
@@ -54,26 +54,18 @@ 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")]
|
||||
public string Secret { get; set; } = Formatter.GenerateString(32);
|
||||
|
||||
[YamlMember(Description = "The lifespan of generated user tokens in hours")]
|
||||
public int TokenDuration { get; set; } = 24 * 10;
|
||||
|
||||
[YamlMember(Description = "This enables the use of the local oauth2 provider, so moonlight will use itself as an oauth2 provider")]
|
||||
public bool EnableLocalOAuth2 { get; set; } = true;
|
||||
public OAuth2Data OAuth2 { get; set; } = new();
|
||||
[YamlMember(Description = "Settings for the user sessions")]
|
||||
public SessionsConfig Sessions { 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/synced user will become an admin automatically")]
|
||||
public bool FirstUserAdmin { get; set; } = true;
|
||||
}
|
||||
|
||||
[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 record SessionsConfig
|
||||
{
|
||||
public string CookieName { get; set; } = "session";
|
||||
public int ExpiresIn { get; set; } = 10;
|
||||
}
|
||||
|
||||
public record DevelopmentConfig
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MoonCore.Exceptions;
|
||||
using MoonCore.Extended.Abstractions;
|
||||
using Moonlight.ApiServer.Configuration;
|
||||
using Moonlight.ApiServer.Database.Entities;
|
||||
using Moonlight.ApiServer.Interfaces;
|
||||
using Moonlight.Shared.Http.Requests.Auth;
|
||||
using Moonlight.ApiServer.Implementations.LocalAuth;
|
||||
using Moonlight.Shared.Http.Responses.Auth;
|
||||
|
||||
namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
||||
@@ -19,93 +12,87 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
||||
[Route("api/auth")]
|
||||
public class AuthController : Controller
|
||||
{
|
||||
private readonly AppConfiguration Configuration;
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
private readonly IOAuth2Provider OAuth2Provider;
|
||||
private readonly IAuthenticationSchemeProvider SchemeProvider;
|
||||
|
||||
public AuthController(
|
||||
AppConfiguration configuration,
|
||||
DatabaseRepository<User> userRepository,
|
||||
IOAuth2Provider oAuth2Provider
|
||||
)
|
||||
// Add schemes which should be offered to the client here
|
||||
private readonly string[] SchemeWhitelist = [LocalAuthConstants.AuthenticationScheme];
|
||||
|
||||
public AuthController(IAuthenticationSchemeProvider schemeProvider)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
OAuth2Provider = oAuth2Provider;
|
||||
Configuration = configuration;
|
||||
SchemeProvider = schemeProvider;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("start")]
|
||||
public async Task<LoginStartResponse> Start()
|
||||
[HttpGet]
|
||||
public async Task<AuthSchemeResponse[]> GetSchemes()
|
||||
{
|
||||
var url = await OAuth2Provider.Start();
|
||||
var schemes = await SchemeProvider.GetAllSchemesAsync();
|
||||
|
||||
return new LoginStartResponse()
|
||||
{
|
||||
Url = url
|
||||
};
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("complete")]
|
||||
public async Task<LoginCompleteResponse> Complete([FromBody] LoginCompleteRequest request)
|
||||
{
|
||||
var user = await OAuth2Provider.Complete(request.Code);
|
||||
|
||||
if (user == null)
|
||||
throw new HttpApiException("Unable to load user data", 500);
|
||||
|
||||
// Generate token
|
||||
var securityTokenDescriptor = new SecurityTokenDescriptor()
|
||||
{
|
||||
Expires = DateTime.Now.AddHours(Configuration.Authentication.TokenDuration),
|
||||
IssuedAt = DateTime.Now,
|
||||
NotBefore = DateTime.Now.AddMinutes(-1),
|
||||
Claims = new Dictionary<string, object>()
|
||||
return schemes
|
||||
.Where(x => SchemeWhitelist.Contains(x.Name))
|
||||
.Select(scheme => new AuthSchemeResponse()
|
||||
{
|
||||
{
|
||||
"userId",
|
||||
user.Id
|
||||
},
|
||||
{
|
||||
"permissions",
|
||||
string.Join(";", user.Permissions)
|
||||
}
|
||||
},
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(Configuration.Authentication.Secret)
|
||||
),
|
||||
SecurityAlgorithms.HmacSha256
|
||||
),
|
||||
Issuer = Configuration.PublicUrl,
|
||||
Audience = Configuration.PublicUrl
|
||||
};
|
||||
DisplayName = scheme.DisplayName ?? scheme.Name,
|
||||
Identifier = scheme.Name
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
|
||||
var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
|
||||
[HttpGet("{identifier:alpha}")]
|
||||
public async Task StartScheme([FromRoute] string identifier)
|
||||
{
|
||||
var scheme = await SchemeProvider.GetSchemeAsync(identifier);
|
||||
|
||||
var jwt = jwtSecurityTokenHandler.WriteToken(securityToken);
|
||||
|
||||
return new()
|
||||
// The check for the whitelist ensures a user isn't starting an auth flow
|
||||
// which isn't meant for users
|
||||
if (scheme == null || !SchemeWhitelist.Contains(scheme.Name))
|
||||
{
|
||||
AccessToken = jwt
|
||||
};
|
||||
await Results
|
||||
.Problem(
|
||||
"Invalid scheme identifier provided",
|
||||
statusCode: 404
|
||||
)
|
||||
.ExecuteAsync(HttpContext);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.ChallengeAsync(
|
||||
scheme.Name,
|
||||
new AuthenticationProperties()
|
||||
{
|
||||
RedirectUri = "/"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("check")]
|
||||
public async Task<CheckResponse> Check()
|
||||
public Task<AuthClaimResponse[]> Check()
|
||||
{
|
||||
var userIdStr = User.FindFirstValue("userId")!;
|
||||
var userId = int.Parse(userIdStr);
|
||||
var user = await UserRepository.Get().FirstAsync(x => x.Id == userId);
|
||||
var username = User.FindFirstValue(ClaimTypes.Name)!;
|
||||
var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
||||
var email = User.FindFirstValue(ClaimTypes.Email)!;
|
||||
var userId = User.FindFirstValue("UserId")!;
|
||||
var permissions = User.FindFirstValue("Permissions")!;
|
||||
|
||||
return new()
|
||||
var claims = new List<AuthClaimResponse>()
|
||||
{
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
Permissions = user.Permissions
|
||||
new(ClaimTypes.Name, username),
|
||||
new(ClaimTypes.NameIdentifier, id),
|
||||
new(ClaimTypes.Email, email),
|
||||
new("UserId", userId),
|
||||
new("Permissions", permissions)
|
||||
};
|
||||
|
||||
return Task.FromResult(
|
||||
claims.ToArray()
|
||||
);
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
public async Task Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync();
|
||||
await Results.Redirect("/").ExecuteAsync(HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<p class="text-base-content/80 mb-4 text-center">
|
||||
No account?
|
||||
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=register"
|
||||
class="link link-animated link-primary font-normal">Create an account</a>
|
||||
<a href="/api/localAuth/register" class="link link-animated link-primary font-normal">Create an account</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,8 +54,5 @@
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public string ClientId { get; set; }
|
||||
[Parameter] public string RedirectUri { get; set; }
|
||||
[Parameter] public string ResponseType { get; set; }
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -47,8 +47,7 @@
|
||||
</form>
|
||||
<p class="text-base-content/80 mb-4 text-center">
|
||||
Already registered?
|
||||
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=login"
|
||||
class="link link-animated link-primary font-normal">Login into your account</a>
|
||||
<a href="/api/localAuth/login" class="link link-animated link-primary font-normal">Login into your account</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,8 +58,5 @@
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public string ClientId { get; set; }
|
||||
[Parameter] public string RedirectUri { get; set; }
|
||||
[Parameter] public string ResponseType { get; set; }
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -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.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.OAuth2.ClientId = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientId);
|
||||
|
||||
await archive.AddText(
|
||||
"core/config.txt",
|
||||
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);
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Database\Migrations\" />
|
||||
<Folder Include="Helpers\" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<PackageId>Moonlight.ApiServer</PackageId>
|
||||
<Version>2.1.7</Version>
|
||||
<Authors>Moonlight Panel</Authors>
|
||||
<Description>A build of the api server for moonlight development</Description>
|
||||
<PackageProjectUrl>https://github.com/Moonlight-Panel/Moonlight</PackageProjectUrl>
|
||||
<DevelopmentDependency>true</DevelopmentDependency>
|
||||
<PackageTags>apiserver</PackageTags>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20" />
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.20" />
|
||||
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7" />
|
||||
<PackageReference Include="MoonCore" Version="1.9.2" />
|
||||
<PackageReference Include="MoonCore.Extended" Version="1.3.5" />
|
||||
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="Riok.Mapperly" Version="4.3.0-next.2" />
|
||||
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.2" />
|
||||
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="storage\**\*" />
|
||||
<Content Remove="storage\**\*" />
|
||||
<None Remove="storage\**\*" />
|
||||
<None Remove="Properties\launchSettings.json" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Database\Migrations\"/>
|
||||
<Folder Include="Helpers\"/>
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<PackageId>Moonlight.ApiServer</PackageId>
|
||||
<Version>2.1.7</Version>
|
||||
<Authors>Moonlight Panel</Authors>
|
||||
<Description>A build of the api server for moonlight development</Description>
|
||||
<PackageProjectUrl>https://github.com/Moonlight-Panel/Moonlight</PackageProjectUrl>
|
||||
<DevelopmentDependency>true</DevelopmentDependency>
|
||||
<PackageTags>apiserver</PackageTags>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20"/>
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.20"/>
|
||||
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"/>
|
||||
<PackageReference Include="MoonCore" Version="1.9.6"/>
|
||||
<PackageReference Include="MoonCore.Extended" Version="1.3.6"/>
|
||||
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||
<PackageReference Include="Riok.Mapperly" Version="4.3.0-next.2"/>
|
||||
<PackageReference Include="SharpZipLib" Version="1.4.2"/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.2"/>
|
||||
<PackageReference Include="Ben.Demystifier" Version="0.4.1"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="storage\**\*"/>
|
||||
<Content Remove="storage\**\*"/>
|
||||
<None Remove="storage\**\*"/>
|
||||
<None Remove="Properties\launchSettings.json"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
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>()
|
||||
{
|
||||
{
|
||||
"apiKeyId",
|
||||
"ApiKeyId",
|
||||
apiKey.Id
|
||||
},
|
||||
{
|
||||
"permissions",
|
||||
"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 Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MoonCore.Extended.JwtInvalidation;
|
||||
using MoonCore.Permissions;
|
||||
using Moonlight.ApiServer.Implementations;
|
||||
using Moonlight.ApiServer.Interfaces;
|
||||
using Moonlight.ApiServer.Implementations.LocalAuth;
|
||||
using Moonlight.ApiServer.Services;
|
||||
|
||||
namespace Moonlight.ApiServer.Startup;
|
||||
@@ -15,8 +16,25 @@ public partial class Startup
|
||||
private Task RegisterAuth()
|
||||
{
|
||||
WebApplicationBuilder.Services
|
||||
.AddAuthentication("coreAuthentication")
|
||||
.AddJwtBearer("coreAuthentication", options =>
|
||||
.AddAuthentication(options => { options.DefaultScheme = "MainScheme"; })
|
||||
.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()
|
||||
{
|
||||
@@ -31,22 +49,116 @@ public partial class Startup
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = Configuration.PublicUrl
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents()
|
||||
{
|
||||
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.AddJwtBearerInvalidation("coreAuthentication");
|
||||
WebApplicationBuilder.Services.AddScoped<IJwtInvalidateHandler, UserAuthInvalidation>();
|
||||
|
||||
WebApplicationBuilder.Services.AddAuthorization();
|
||||
|
||||
WebApplicationBuilder.Services.AddAuthorizationPermissions(options =>
|
||||
{
|
||||
options.ClaimName = "permissions";
|
||||
options.ClaimName = "Permissions";
|
||||
options.Prefix = "permissions:";
|
||||
});
|
||||
|
||||
WebApplicationBuilder.Services.AddScoped<UserAuthService>();
|
||||
WebApplicationBuilder.Services.AddScoped<ApiKeyAuthService>();
|
||||
|
||||
// Add local oauth2 provider if enabled
|
||||
if (Configuration.Authentication.EnableLocalOAuth2)
|
||||
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>();
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ public partial class Startup
|
||||
{
|
||||
{ "Default", "Information" },
|
||||
{ "Microsoft.AspNetCore", "Warning" },
|
||||
{ "System.Net.Http.HttpClient", "Warning" }
|
||||
{ "System.Net.Http.HttpClient", "Warning" },
|
||||
{ "Moonlight.ApiServer.Implementations.LocalAuth.LocalAuthHandler", "Warning" }
|
||||
};
|
||||
|
||||
var logLevelsJson = JsonSerializer.Serialize(defaultLogLevels);
|
||||
|
||||
Reference in New Issue
Block a user