Added google and discord oauth2. Fixed menu bugs

This commit is contained in:
Marcel Baumgartner 2023-02-23 10:36:31 +01:00
parent 8f3f9fa1fb
commit 60693d25da
12 changed files with 555 additions and 47 deletions

View file

@ -0,0 +1,132 @@
using Logging.Net;
using Microsoft.AspNetCore.Mvc;
using Moonlight.App.Exceptions;
using Moonlight.App.Helpers;
using Moonlight.App.Repositories;
using Moonlight.App.Services;
using Moonlight.App.Services.OAuth2;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Http.Controllers.Api.Moonlight;
[ApiController]
[Route("api/moonlight/oauth2")]
public class OAuth2Controller : Controller
{
private readonly GoogleOAuth2Service GoogleOAuth2Service;
private readonly DiscordOAuth2Service DiscordOAuth2Service;
private readonly UserRepository UserRepository;
private readonly UserService UserService;
public OAuth2Controller(
GoogleOAuth2Service googleOAuth2Service,
UserRepository userRepository,
UserService userService,
DiscordOAuth2Service discordOAuth2Service)
{
GoogleOAuth2Service = googleOAuth2Service;
UserRepository = userRepository;
UserService = userService;
DiscordOAuth2Service = discordOAuth2Service;
}
[HttpGet("google")]
public async Task<ActionResult> Google([FromQuery] string code)
{
try
{
var userData = await GoogleOAuth2Service.HandleCode(code);
if (userData == null)
return Redirect("/login");
try
{
var user = UserRepository.Get().FirstOrDefault(x => x.Email == userData.Email);
string token;
if (user == null)
{
token = await UserService.Register(
userData.Email,
StringHelper.GenerateString(32),
userData.FirstName,
userData.LastName
);
}
else
{
token = await UserService.GenerateToken(user);
}
Response.Cookies.Append("token", token, new ()
{
Expires = new DateTimeOffset(DateTime.UtcNow.AddDays(10))
});
return Redirect("/");
}
catch (Exception e)
{
Logger.Warn(e.Message);
return Redirect("/login");
}
}
catch (Exception e)
{
Logger.Warn(e.Message);
return BadRequest();
}
}
[HttpGet("discord")]
public async Task<ActionResult> Discord([FromQuery] string code)
{
try
{
var userData = await DiscordOAuth2Service.HandleCode(code);
if (userData == null)
return Redirect("/login");
try
{
var user = UserRepository.Get().FirstOrDefault(x => x.Email == userData.Email);
string token;
if (user == null)
{
token = await UserService.Register(
userData.Email,
StringHelper.GenerateString(32),
userData.FirstName,
userData.LastName
);
}
else
{
token = await UserService.GenerateToken(user);
}
Response.Cookies.Append("token", token, new ()
{
Expires = new DateTimeOffset(DateTime.UtcNow.AddDays(10))
});
return Redirect("/");
}
catch (Exception e)
{
Logger.Warn(e.Message);
return Redirect("/login");
}
}
catch (Exception e)
{
Logger.Warn(e.Message);
return BadRequest();
}
}
}

View file

@ -0,0 +1,21 @@
using Newtonsoft.Json;
namespace Moonlight.App.Models.Google.Requests;
public class GoogleOAuth2CodePayload
{
[JsonProperty("grant_type")]
public string GrantType { get; set; } = "authorization_code";
[JsonProperty("code")]
public string Code { get; set; }
[JsonProperty("client_id")]
public string ClientId { get; set; }
[JsonProperty("client_secret")]
public string ClientSecret { get; set; }
[JsonProperty("redirect_uri")]
public string RedirectUri { get; set; }
}

View file

@ -0,0 +1,127 @@
using System.Text;
using Logging.Net;
using Moonlight.App.Database.Entities;
using Moonlight.App.Exceptions;
using Moonlight.App.Models.Google.Requests;
using RestSharp;
namespace Moonlight.App.Services.OAuth2;
public class DiscordOAuth2Service
{
private readonly bool Enable;
private readonly string ClientId;
private readonly string ClientSecret;
private readonly bool EnableOverrideUrl;
private readonly string OverrideUrl;
private readonly string AppUrl;
public DiscordOAuth2Service(ConfigService configService)
{
var config = configService
.GetSection("Moonlight")
.GetSection("OAuth2");
Enable = config
.GetSection("Discord")
.GetValue<bool>("Enable");
if (Enable)
{
ClientId = config.GetSection("Discord").GetValue<string>("ClientId");
ClientSecret = config.GetSection("Discord").GetValue<string>("ClientSecret");
}
EnableOverrideUrl = config.GetValue<bool>("EnableOverrideUrl");
if (EnableOverrideUrl)
OverrideUrl = config.GetValue<string>("OverrideUrl");
AppUrl = configService.GetSection("Moonlight").GetValue<string>("AppUrl");
}
public Task<string> GetUrl()
{
if (!Enable)
throw new DisplayException("Discord OAuth2 not enabled");
string url = $"https://discord.com/api/oauth2/authorize?client_id={ClientId}" +
$"&redirect_uri={GetBaseUrl()}/api/moonlight/oauth2/discord" +
"&response_type=code&scope=identify%20email";
return Task.FromResult(
url
);
}
public async Task<User?> HandleCode(string code)
{
// Generate access token
var endpoint = GetBaseUrl() + "/api/moonlight/oauth2/discord";
var discordEndpoint = "https://discordapp.com/api/oauth2/token";
using var client = new RestClient();
var request = new RestRequest(discordEndpoint);
request.AddParameter("client_id", ClientId);
request.AddParameter("client_secret", ClientSecret);
request.AddParameter("grant_type", "authorization_code");
request.AddParameter("code", code);
request.AddParameter("redirect_uri", endpoint);
var response = await client.ExecutePostAsync(request);
if (!response.IsSuccessful)
{
//TODO: Maybe add better error handling
Logger.Debug("oAuth2 validate error: " + response.Content!);
return null;
}
// parse response
var data = new ConfigurationBuilder().AddJsonStream(
new MemoryStream(Encoding.ASCII.GetBytes(response.Content!))
).Build();
var accessToken = data.GetValue<string>("access_token");
// Now, we will call the google api with our access token to get the data we need
var googlePeopleEndpoint = "https://discordapp.com/api/users/@me";
var getRequest = new RestRequest(googlePeopleEndpoint);
getRequest.AddHeader("Authorization", $"Bearer {accessToken}");
var getResponse = await client.ExecuteGetAsync(getRequest);
if (!getResponse.IsSuccessful)
{
//TODO: Maybe add better error handling
Logger.Debug("OAuth2 api access error: " + getResponse.Content!);
return null;
}
// Parse response
var getData = new ConfigurationBuilder().AddJsonStream(
new MemoryStream(Encoding.ASCII.GetBytes(getResponse.Content!))
).Build();
return new User()
{
Email = getData.GetValue<string>("email"),
FirstName = getData.GetValue<string>("username"),
LastName = getData.GetValue<string>("discriminator")
};
}
private string GetBaseUrl()
{
if (EnableOverrideUrl)
return OverrideUrl;
return AppUrl;
}
}

View file

@ -0,0 +1,149 @@
using System.Text;
using Logging.Net;
using Moonlight.App.Database.Entities;
using Moonlight.App.Exceptions;
using Moonlight.App.Models.Google.Requests;
using RestSharp;
namespace Moonlight.App.Services.OAuth2;
public class GoogleOAuth2Service
{
private readonly bool EnableGoogle;
private readonly string GoogleClientId;
private readonly string GoogleClientSecret;
private readonly bool EnableOverrideUrl;
private readonly string OverrideUrl;
private readonly string AppUrl;
public GoogleOAuth2Service(ConfigService configService)
{
var config = configService
.GetSection("Moonlight")
.GetSection("OAuth2");
EnableGoogle = config
.GetSection("Google")
.GetValue<bool>("Enable");
if (EnableGoogle)
{
GoogleClientId = config.GetSection("Google").GetValue<string>("ClientId");
GoogleClientSecret = config.GetSection("Google").GetValue<string>("ClientSecret");
}
EnableOverrideUrl = config.GetValue<bool>("EnableOverrideUrl");
if (EnableOverrideUrl)
OverrideUrl = config.GetValue<string>("OverrideUrl");
AppUrl = configService.GetSection("Moonlight").GetValue<string>("AppUrl");
}
public Task<string> GetUrl()
{
if (!EnableGoogle)
throw new DisplayException("Google OAuth2 not enabled");
var endpoint = GetBaseUrl() + "/api/moonlight/oauth2/google";
var scope = "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email";
return Task.FromResult(
$"https://accounts.google.com/o/oauth2/auth?response_type=code&client_id={GoogleClientId}&redirect_uri={endpoint}&scope={scope}"
);
}
public async Task<User?> HandleCode(string code)
{
// Generate access token
var endpoint = GetBaseUrl() + "/api/moonlight/oauth2/google";
var googleEndpoint = "https://oauth2.googleapis.com/token";
// Setup payload
var payload = new GoogleOAuth2CodePayload()
{
Code = code,
RedirectUri = endpoint,
ClientId = GoogleClientId,
ClientSecret = GoogleClientSecret
};
using var client = new RestClient();
var request = new RestRequest(googleEndpoint);
request.AddBody(payload);
var response = await client.ExecutePostAsync(request);
if (!response.IsSuccessful)
{
//TODO: Maybe add better error handling
Logger.Debug("oAuth2 validate error: " + response.Content!);
return null;
}
// parse response
var data = new ConfigurationBuilder().AddJsonStream(
new MemoryStream(Encoding.ASCII.GetBytes(response.Content!))
).Build();
var accessToken = data.GetValue<string>("access_token");
// Now, we will call the google api with our access token to get the data we need
var googlePeopleEndpoint = "https://people.googleapis.com/v1/people/me";
var getRequest = new RestRequest(googlePeopleEndpoint);
getRequest.AddHeader("Authorization", $"Bearer {accessToken}");
getRequest.AddParameter("personFields", "names,emailAddresses");
var getResponse = await client.ExecuteGetAsync(getRequest);
if (!getResponse.IsSuccessful)
{
//TODO: Maybe add better error handling
Logger.Debug("OAuth2 api access error: " + getResponse.Content!);
return null;
}
// Parse response
var getData = new ConfigurationBuilder().AddJsonStream(
new MemoryStream(Encoding.ASCII.GetBytes(getResponse.Content!))
).Build();
var firstName = getData
.GetSection("names")
.GetChildren()
.First()
.GetValue<string>("givenName");
var lastName = getData
.GetSection("names")
.GetChildren()
.First()
.GetValue<string>("familyName");
var email = getData
.GetSection("emailAddresses")
.GetChildren()
.First()
.GetValue<string>("value");
return new()
{
Email = email,
FirstName = firstName,
LastName = lastName
};
}
private string GetBaseUrl()
{
if (EnableOverrideUrl)
return OverrideUrl;
return AppUrl;
}
}

View file

@ -68,13 +68,7 @@ public class UserService
//var mail = new WelcomeMail(user);
//await MailService.Send(mail, user);
return JwtBuilder.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.WithSecret(JwtSecret)
.AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds())
.AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds())
.AddClaim("userid", user.Id)
.Encode();
return await GenerateToken(user);
}
public Task<bool> CheckTotp(string email, string password)
@ -123,13 +117,7 @@ public class UserService
{
//AuditLogService.Log("login:success", $"{user.Email} has successfully logged in");
return JwtBuilder.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.WithSecret(JwtSecret)
.AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds())
.AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds())
.AddClaim("userid", user.Id)
.Encode();
return await GenerateToken(user);
}
else
{
@ -141,17 +129,11 @@ public class UserService
{
//AuditLogService.Log("login:success", $"{user.Email} has successfully logged in");
return JwtBuilder.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.WithSecret(JwtSecret)
.AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds())
.AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds())
.AddClaim("userid", user.Id)
.Encode();
return await GenerateToken(user!);
}
}
public async Task ChangePassword(User user, string password)
public Task ChangePassword(User user, string password)
{
user.Password = BCrypt.Net.BCrypt.HashPassword(password);
user.TokenValidTime = DateTime.Now;
@ -161,6 +143,8 @@ public class UserService
//await MailService.Send(mail, user);
//AuditLogService.Log("password:change", "The password has been set to a new one");
return Task.CompletedTask;
}
public Task<User> SftpLogin(int id, string password)
@ -179,4 +163,17 @@ public class UserService
//TODO: Log
throw new Exception("Invalid userid or password");
}
public Task<string> GenerateToken(User user)
{
var token = JwtBuilder.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.WithSecret(JwtSecret)
.AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds())
.AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds())
.AddClaim("userid", user.Id)
.Encode();
return Task.FromResult(token);
}
}

View file

@ -59,6 +59,7 @@
<ItemGroup>
<Folder Include="App\Http\Middleware" />
<Folder Include="App\Models\AuditLogData" />
<Folder Include="App\Models\Google\Resources" />
<Folder Include="resources\lang" />
<Folder Include="wwwroot\assets\media" />
</ItemGroup>

View file

@ -7,6 +7,7 @@ using Moonlight.App.Repositories;
using Moonlight.App.Repositories.Servers;
using Moonlight.App.Services;
using Moonlight.App.Services.Interop;
using Moonlight.App.Services.OAuth2;
using Moonlight.App.Services.Sessions;
using Moonlight.App.Services.Support;
@ -56,6 +57,8 @@ namespace Moonlight
builder.Services.AddSingleton<PaperService>();
builder.Services.AddScoped<ClipboardService>();
builder.Services.AddSingleton<ResourceService>();
builder.Services.AddScoped<GoogleOAuth2Service>();
builder.Services.AddScoped<DiscordOAuth2Service>();
builder.Services.AddScoped<AuditLogService>();
builder.Services.AddScoped<SystemAuditLogService>();

View file

@ -9,6 +9,7 @@
@using Moonlight.App.Services
@using Moonlight.App.Exceptions
@using Logging.Net
@using Moonlight.App.Services.OAuth2
@using Moonlight.App.Services.Sessions
@inject AlertService AlertService
@ -16,6 +17,8 @@
@inject SmartTranslateService SmartTranslateService
@inject CookieService CookieService
@inject NavigationManager NavigationManager
@inject GoogleOAuth2Service GoogleOAuth2Service
@inject DiscordOAuth2Service DiscordOAuth2Service
<div class="d-flex flex-center">
<div class="card rounded-3 w-md-550px">
@ -35,17 +38,17 @@
<div class="row g-3 mb-9">
<div class="col-md-6">
<a href="#" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<a href="#" @onclick:preventDefault @onclick="DoDiscord" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<div class="h-15px me-3">
<i class="bx bx-md bxl-discord-alt"></i>
<i class="mb-1 bx bx-md bxl-discord-alt"></i>
</div>
<TL>Sign in with Discord</TL>
</a>
</div>
<div class="col-md-6">
<a href="#" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<a href="#" @onclick:preventDefault @onclick="DoGoogle" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<div class="h-15px me-3">
<i class="bx bx-md bxl-google"></i>
<i class="mb-1 bx bx-md bxl-google"></i>
</div>
<TL>Sign in with Google</TL>
</a>
@ -158,4 +161,16 @@
Logger.Error(e);
}
}
private async Task DoGoogle()
{
var url = await GoogleOAuth2Service.GetUrl();
NavigationManager.NavigateTo(url, true);
}
private async Task DoDiscord()
{
var url = await DiscordOAuth2Service.GetUrl();
NavigationManager.NavigateTo(url, true);
}
}

View file

@ -6,8 +6,12 @@
*@
@using Moonlight.App.Services
@using Moonlight.App.Services.OAuth2
@inject SmartTranslateService SmartTranslateService
@inject GoogleOAuth2Service GoogleOAuth2Service
@inject NavigationManager NavigationManager
@inject DiscordOAuth2Service DiscordOAuth2Service
<div class="d-flex flex-center">
<div class="card rounded-3 w-md-550px">
@ -25,17 +29,17 @@
<div class="row g-3 mb-9">
<div class="col-md-6">
<a href="#" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<a href="#" @onclick:preventDefault @onclick="DoDiscord" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<div class="h-15px me-3">
<i class="bx bx-md bxl-discord-alt"></i>
<i class="mb-1 bx bx-md bxl-discord-alt"></i>
</div>
<TL>Sign up with Discord</TL>
</a>
</div>
<div class="col-md-6">
<a href="#" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<a href="#" @onclick:preventDefault @onclick="DoGoogle" class="btn btn-flex btn-outline btn-text-gray-700 btn-active-color-primary bg-state-light flex-center text-nowrap w-100">
<div class="h-15px me-3">
<i class="bx bx-md bxl-google"></i>
<i class="mb-1 bx bx-md bxl-google"></i>
</div>
<TL>Sign up with Google</TL>
</a>
@ -85,4 +89,19 @@
</div>
</div>
</div>
</div>
</div>
@code
{
private async Task DoGoogle()
{
var url = await GoogleOAuth2Service.GetUrl();
NavigationManager.NavigateTo(url, true);
}
private async Task DoDiscord()
{
var url = await DiscordOAuth2Service.GetUrl();
NavigationManager.NavigateTo(url, true);
}
}

View file

@ -1,10 +1,14 @@
@using Logging.Net
@using Moonlight.App.Exceptions
@using Moonlight.App.Services
@using Moonlight.App.Services.Interop
@using Moonlight.App.Services.Sessions
@inherits ErrorBoundary
@inject IdentityService IdentityService
@inject AlertService AlertService
@inject SmartTranslateService SmartTranslateService
@if (CurrentException is null)
{
@ -60,6 +64,21 @@ else
Logger.Error(exception);
await base.OnErrorAsync(exception);
if (exception is DisplayException displayException)
{
Task.Run(async () =>
{
await AlertService.Error(
SmartTranslateService.Translate("Error"),
SmartTranslateService.Translate(displayException.Message)
);
});
Recover();
await InvokeAsync(StateHasChanged);
}
}
public new void Recover()

View file

@ -8,6 +8,7 @@
@using Moonlight.App.Services
@using Moonlight.App.Services.Interop
@using Moonlight.App.Services.Sessions
@using Logging.Net
@layout ThemeInit
@implements IDisposable
@ -31,7 +32,7 @@
{
if (!string.IsNullOrEmpty(pathPart))
{
if(pathPart == pathParts.Last())
if (pathPart == pathParts.Last())
title += $"{pathPart.FirstCharToUpper()} ";
else
title += $"{pathPart.FirstCharToUpper()} - ";
@ -128,11 +129,6 @@
AddBodyAttribute("data-kt-app-toolbar-enabled", "true");
AddBodyClass("app-default");
JsRuntime.InvokeVoidAsync("KTModalUpgradePlan.init");
JsRuntime.InvokeVoidAsync("KTCreateApp.init");
JsRuntime.InvokeVoidAsync("KTModalUserSearch.init");
JsRuntime.InvokeVoidAsync("KTModalNewTarget.init");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@ -142,9 +138,7 @@
try
{
User = await IdentityService.Get();
await InvokeAsync(StateHasChanged);
await Task.Delay(300);
await JsRuntime.InvokeVoidAsync("document.body.removeAttribute", "data-kt-app-reset-transition");
await JsRuntime.InvokeVoidAsync("document.body.removeAttribute", "data-kt-app-page-loading");
@ -157,19 +151,24 @@
NavigationManager.LocationChanged += (sender, args) => { SessionService.Refresh(); };
MessageService.Subscribe<MainLayout, SupportMessage>(
$"support.{User.Id}.message",
this,
$"support.{User.Id}.message",
this,
async message =>
{
if (!NavigationManager.Uri.EndsWith("/support") && (message.IsSupport || message.IsSystem))
{
await ToastService.Info($"Support: {message.Message}");
}
});
if (!NavigationManager.Uri.EndsWith("/support") && (message.IsSupport || message.IsSystem))
{
await ToastService.Info($"Support: {message.Message}");
}
});
RunDelayedMenu(0);
RunDelayedMenu(1);
RunDelayedMenu(3);
RunDelayedMenu(5);
}
catch (Exception)
{
// ignored
// ignored
}
}
}
@ -193,4 +192,21 @@
{
JsRuntime.InvokeVoidAsync("document.body.classList.add", className);
}
private void RunDelayedMenu(int seconds)
{
Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(seconds));
await JsRuntime.InvokeVoidAsync("KTMenu.initHandlers");
}
catch (Exception e)
{
Logger.Warn("Delayed menu error");
Logger.Warn(e);
}
});
}
}

View file

@ -2462,8 +2462,17 @@ KTMenu.updateDropdowns = function() {
}
}
// Bug fix for menu load initializing
KTMenu.hasInit = false;
// Global handlers
KTMenu.initHandlers = function() {
if(KTMenu.hasInit)
return;
KTMenu.hasInit = true;
// Dropdown handler
document.addEventListener("click", function(e) {
var items = document.querySelectorAll('.show.menu-dropdown[data-kt-menu-trigger]');