Started implementing oauth2 based on MoonCore helper services
Its more or less a test how well the helper services improve the implementation. I havent implemented anything fancy here atm. Just testing the oauth2 flow
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
namespace Moonlight.ApiServer.Configuration;
|
using MoonCore.Helpers;
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Configuration;
|
||||||
|
|
||||||
public class AppConfiguration
|
public class AppConfiguration
|
||||||
{
|
{
|
||||||
|
public string PublicUrl { get; set; } = "http://localhost:5165";
|
||||||
|
|
||||||
public DatabaseConfig Database { get; set; } = new();
|
public DatabaseConfig Database { get; set; } = new();
|
||||||
public AuthenticationConfig Authentication { get; set; } = new();
|
public AuthenticationConfig Authentication { get; set; } = new();
|
||||||
public DevelopmentConfig Development { get; set; } = new();
|
public DevelopmentConfig Development { get; set; } = new();
|
||||||
@@ -19,12 +23,25 @@ public class AppConfiguration
|
|||||||
|
|
||||||
public class AuthenticationConfig
|
public class AuthenticationConfig
|
||||||
{
|
{
|
||||||
public string Secret { get; set; } = Guid
|
public string MlAccessSecret { get; set; } = Formatter.GenerateString(32);
|
||||||
.NewGuid()
|
public string MlRefreshSecret { get; set; } = Formatter.GenerateString(32);
|
||||||
.ToString()
|
|
||||||
.Replace("-", "");
|
public string Secret { get; set; } = Formatter.GenerateString(32);
|
||||||
|
|
||||||
public int TokenDuration { get; set; } = 10;
|
public int TokenDuration { get; set; } = 10;
|
||||||
|
|
||||||
|
public bool UseLocalOAuth2Service { get; set; } = true;
|
||||||
|
public string AccessSecret { get; set; } = Formatter.GenerateString(32);
|
||||||
|
public string RefreshSecret { get; set; } = Formatter.GenerateString(32);
|
||||||
|
public string ClientId { get; set; } = Formatter.GenerateString(8);
|
||||||
|
public string ClientSecret { get; set; } = Formatter.GenerateString(32);
|
||||||
|
public string? AuthorizationUri { get; set; }
|
||||||
|
public string? AuthorizationRedirect { get; set; }
|
||||||
|
public string? AccessEndpoint { get; set; }
|
||||||
|
public string? RefreshEndpoint { get; set; }
|
||||||
|
|
||||||
|
// Local OAuth2 Service
|
||||||
|
public string CodeSecret { get; set; } = Formatter.GenerateString(32);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DevelopmentConfig
|
public class DevelopmentConfig
|
||||||
|
|||||||
@@ -10,4 +10,8 @@ public class User
|
|||||||
|
|
||||||
public DateTime TokenValidTimestamp { get; set; } = DateTime.UtcNow;
|
public DateTime TokenValidTimestamp { get; set; } = DateTime.UtcNow;
|
||||||
public string PermissionsJson { get; set; } = "[]";
|
public string PermissionsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
public string AccessToken { get; set; } = "";
|
||||||
|
public string RefreshToken { get; set; } = "";
|
||||||
|
public DateTime RefreshTimestamp { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
74
Moonlight.ApiServer/Database/Migrations/20241017214600_AddedAccessAndRefreshTokenFields.Designer.cs
generated
Normal file
74
Moonlight.ApiServer/Database/Migrations/20241017214600_AddedAccessAndRefreshTokenFields.Designer.cs
generated
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Moonlight.ApiServer.Database;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Database.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CoreDataContext))]
|
||||||
|
[Migration("20241017214600_AddedAccessAndRefreshTokenFields")]
|
||||||
|
partial class AddedAccessAndRefreshTokenFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("Core")
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("AccessToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("PermissionsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RefreshTimestamp")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("TokenValidTimestamp")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Users", "Core");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddedAccessAndRefreshTokenFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AccessToken",
|
||||||
|
schema: "Core",
|
||||||
|
table: "Users",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "RefreshTimestamp",
|
||||||
|
schema: "Core",
|
||||||
|
table: "Users",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RefreshToken",
|
||||||
|
schema: "Core",
|
||||||
|
table: "Users",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AccessToken",
|
||||||
|
schema: "Core",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RefreshTimestamp",
|
||||||
|
schema: "Core",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RefreshToken",
|
||||||
|
schema: "Core",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ namespace Moonlight.ApiServer.Database.Migrations
|
|||||||
|
|
||||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("AccessToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
@@ -43,6 +47,13 @@ namespace Moonlight.ApiServer.Database.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RefreshTimestamp")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
b.Property<DateTime>("TokenValidTimestamp")
|
b.Property<DateTime>("TokenValidTimestamp")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoonCore.Extended.Abstractions;
|
||||||
|
using MoonCore.Extended.Helpers;
|
||||||
|
using MoonCore.Extended.OAuth2.ApiServer;
|
||||||
|
using MoonCore.Helpers;
|
||||||
|
using MoonCore.Services;
|
||||||
using Moonlight.ApiServer.Attributes;
|
using Moonlight.ApiServer.Attributes;
|
||||||
|
using Moonlight.ApiServer.Configuration;
|
||||||
|
using Moonlight.ApiServer.Database.Entities;
|
||||||
using Moonlight.ApiServer.Helpers.Authentication;
|
using Moonlight.ApiServer.Helpers.Authentication;
|
||||||
using Moonlight.ApiServer.Services;
|
using Moonlight.ApiServer.Services;
|
||||||
using Moonlight.Shared.Http.Requests.Auth;
|
using Moonlight.Shared.Http.Requests.Auth;
|
||||||
@@ -11,37 +18,56 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
|||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController : Controller
|
public class AuthController : Controller
|
||||||
{
|
{
|
||||||
private readonly AuthService AuthService;
|
private readonly OAuth2Service OAuth2Service;
|
||||||
|
private readonly TokenHelper TokenHelper;
|
||||||
|
private readonly ConfigService<AppConfiguration> ConfigService;
|
||||||
|
private readonly DatabaseRepository<User> UserRepository;
|
||||||
|
|
||||||
public AuthController(AuthService authService)
|
public AuthController(OAuth2Service oAuth2Service, TokenHelper tokenHelper, DatabaseRepository<User> userRepository, ConfigService<AppConfiguration> configService)
|
||||||
{
|
{
|
||||||
AuthService = authService;
|
OAuth2Service = oAuth2Service;
|
||||||
|
TokenHelper = tokenHelper;
|
||||||
|
UserRepository = userRepository;
|
||||||
|
ConfigService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpGet("start")]
|
||||||
public async Task<LoginResponse> Login([FromBody] LoginRequest request)
|
public async Task<AuthStartResponse> Start()
|
||||||
{
|
{
|
||||||
var user = await AuthService.Login(request.Email, request.Password);
|
var data = await OAuth2Service.StartAuthorizing();
|
||||||
|
|
||||||
return new LoginResponse()
|
return Mapper.Map<AuthStartResponse>(data);
|
||||||
{
|
|
||||||
Token = await AuthService.GenerateToken(user)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpGet("handle")]
|
||||||
public async Task<RegisterResponse> Register([FromBody] RegisterRequest request)
|
public async Task Handle([FromQuery(Name = "code")] string code)
|
||||||
{
|
{
|
||||||
var user = await AuthService.Register(
|
//TODO: Validate jwt syntax
|
||||||
request.Username,
|
|
||||||
request.Email,
|
|
||||||
request.Password
|
|
||||||
);
|
|
||||||
|
|
||||||
return new RegisterResponse()
|
var accessData = await OAuth2Service.RequestAccess(code);
|
||||||
|
|
||||||
|
//TODO: Add modular oauth2 consumer system
|
||||||
|
var userId = 1;
|
||||||
|
|
||||||
|
var user = UserRepository.Get().First(x => x.Id == userId);
|
||||||
|
|
||||||
|
user.AccessToken = accessData.AccessToken;
|
||||||
|
user.RefreshToken = accessData.RefreshToken;
|
||||||
|
user.RefreshTimestamp = DateTime.UtcNow.AddSeconds(accessData.ExpiresIn);
|
||||||
|
|
||||||
|
UserRepository.Update(user);
|
||||||
|
|
||||||
|
var authConfig = ConfigService.Get().Authentication;
|
||||||
|
var tokenPair = await TokenHelper.GeneratePair(authConfig.MlAccessSecret, authConfig.MlAccessSecret, data =>
|
||||||
{
|
{
|
||||||
Token = await AuthService.GenerateToken(user)
|
data.Add("userId", user.Id.ToString());
|
||||||
};
|
});
|
||||||
|
|
||||||
|
Response.Cookies.Append("ml-access", tokenPair.AccessToken);
|
||||||
|
Response.Cookies.Append("ml-refresh", tokenPair.RefreshToken);
|
||||||
|
Response.Cookies.Append("ml-timestamp", DateTimeOffset.UtcNow.AddSeconds(3600).ToUnixTimeSeconds().ToString());
|
||||||
|
|
||||||
|
Response.Redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("check")]
|
[HttpGet("check")]
|
||||||
|
|||||||
133
Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs
Normal file
133
Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoonCore.Exceptions;
|
||||||
|
using MoonCore.Extended.Abstractions;
|
||||||
|
using MoonCore.Extended.OAuth2.AuthServer;
|
||||||
|
using MoonCore.Extended.OAuth2.Models;
|
||||||
|
using MoonCore.Services;
|
||||||
|
using Moonlight.ApiServer.Configuration;
|
||||||
|
using Moonlight.ApiServer.Database.Entities;
|
||||||
|
using Moonlight.ApiServer.Services;
|
||||||
|
|
||||||
|
namespace Moonlight.ApiServer.Http.Controllers.OAuth2;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("oauth2")]
|
||||||
|
public class OAuth2Controller : Controller
|
||||||
|
{
|
||||||
|
private readonly OAuth2Service OAuth2Service;
|
||||||
|
private readonly AuthService AuthService;
|
||||||
|
private readonly DatabaseRepository<User> UserRepository;
|
||||||
|
private readonly ConfigService<AppConfiguration> ConfigService;
|
||||||
|
|
||||||
|
public OAuth2Controller(OAuth2Service oAuth2Service, ConfigService<AppConfiguration> configService,
|
||||||
|
AuthService authService, DatabaseRepository<User> userRepository)
|
||||||
|
{
|
||||||
|
OAuth2Service = oAuth2Service;
|
||||||
|
ConfigService = configService;
|
||||||
|
AuthService = authService;
|
||||||
|
UserRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("authorize")]
|
||||||
|
public async Task Authorize(
|
||||||
|
[FromQuery(Name = "response_type")] string responseType,
|
||||||
|
[FromQuery(Name = "client_id")] string clientId,
|
||||||
|
[FromQuery(Name = "redirect_uri")] string redirectUri
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (responseType != "code")
|
||||||
|
throw new HttpApiException("Invalid response type", 400);
|
||||||
|
|
||||||
|
var config = ConfigService.Get();
|
||||||
|
|
||||||
|
// TODO: This call should be handled by the OAuth2Service
|
||||||
|
if (clientId != config.Authentication.ClientId)
|
||||||
|
throw new HttpApiException("Invalid client id", 400);
|
||||||
|
|
||||||
|
if (redirectUri != (config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle"))
|
||||||
|
throw new HttpApiException("Invalid redirect uri", 400);
|
||||||
|
|
||||||
|
Response.StatusCode = 200;
|
||||||
|
await Response.WriteAsync(
|
||||||
|
"<h1>Login lol</h1><br />" +
|
||||||
|
"<br />" +
|
||||||
|
"<br />" +
|
||||||
|
"<form method=\"post\">" +
|
||||||
|
"<label for=\"email\">Email:</label>" +
|
||||||
|
"<input type=\"email\" id=\"email\" name=\"email\"><br>" +
|
||||||
|
"<br>" +
|
||||||
|
"<label for=\"password\">Password:</label>" +
|
||||||
|
"<input type=\"password\" id=\"password\" name=\"password\"><br>" +
|
||||||
|
"<br>" +
|
||||||
|
"<input type=\"submit\" value=\"Submit\">" +
|
||||||
|
"</form>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("authorize")]
|
||||||
|
public async Task AuthorizePost(
|
||||||
|
[FromQuery(Name = "response_type")] string responseType,
|
||||||
|
[FromQuery(Name = "client_id")] string clientId,
|
||||||
|
[FromQuery(Name = "redirect_uri")] string redirectUri,
|
||||||
|
[FromForm(Name = "email")] string email,
|
||||||
|
[FromForm(Name = "password")] string password
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (responseType != "code")
|
||||||
|
throw new HttpApiException("Invalid response type", 400);
|
||||||
|
|
||||||
|
var config = ConfigService.Get();
|
||||||
|
|
||||||
|
// TODO: This call should be handled by the OAuth2Service
|
||||||
|
if (clientId != config.Authentication.ClientId)
|
||||||
|
throw new HttpApiException("Invalid client id", 400);
|
||||||
|
|
||||||
|
if (redirectUri != (config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle"))
|
||||||
|
throw new HttpApiException("Invalid redirect uri", 400);
|
||||||
|
|
||||||
|
var user = await AuthService.Login(email, password);
|
||||||
|
|
||||||
|
var code = await OAuth2Service.GenerateCode(data => { data.Add("userId", user.Id.ToString()); });
|
||||||
|
|
||||||
|
var redirectUrl = redirectUri +
|
||||||
|
$"?code={code}";
|
||||||
|
|
||||||
|
Response.Redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("access")]
|
||||||
|
public async Task<AccessData> Access(
|
||||||
|
[FromForm(Name = "client_id")] string clientId,
|
||||||
|
[FromForm(Name = "client_secret")] string clientSecret,
|
||||||
|
[FromForm(Name = "redirect_uri")] string redirectUri,
|
||||||
|
[FromForm(Name = "grant_type")] string grantType,
|
||||||
|
[FromForm(Name = "code")] string code
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (grantType != "authorization_code")
|
||||||
|
throw new HttpApiException("Invalid grant type", 400);
|
||||||
|
|
||||||
|
User? user = null;
|
||||||
|
|
||||||
|
var access = await OAuth2Service.ValidateAccess(clientId, clientSecret, redirectUri, code, data =>
|
||||||
|
{
|
||||||
|
if (!data.TryGetValue("userId", out var userIdStr))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!int.TryParse(userIdStr, out var userId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
user = UserRepository.Get().FirstOrDefault(x => x.Id == userId);
|
||||||
|
|
||||||
|
return user != null;
|
||||||
|
}, data =>
|
||||||
|
{
|
||||||
|
data.Add("userId", user!.Id.ToString());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (access == null)
|
||||||
|
throw new HttpApiException("Unable to validate access", 400);
|
||||||
|
|
||||||
|
return access;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using MoonCore.Extended.Abstractions;
|
using MoonCore.Extended.Abstractions;
|
||||||
using MoonCore.Extended.Helpers;
|
using MoonCore.Extended.Helpers;
|
||||||
|
using MoonCore.Extended.Models;
|
||||||
|
using MoonCore.Extended.OAuth2.ApiServer;
|
||||||
using MoonCore.Services;
|
using MoonCore.Services;
|
||||||
using Moonlight.ApiServer.Configuration;
|
using Moonlight.ApiServer.Configuration;
|
||||||
using Moonlight.ApiServer.Database.Entities;
|
using Moonlight.ApiServer.Database.Entities;
|
||||||
@@ -28,6 +30,82 @@ public class AuthenticationMiddleware
|
|||||||
private async Task Authenticate(HttpContext context)
|
private async Task Authenticate(HttpContext context)
|
||||||
{
|
{
|
||||||
var request = context.Request;
|
var request = context.Request;
|
||||||
|
|
||||||
|
if (!request.Cookies.TryGetValue("ml-access", out var accessToken) ||
|
||||||
|
!request.Cookies.TryGetValue("ml-refresh", out var refreshToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// TODO: Validate if both are valid jwts (maybe)
|
||||||
|
|
||||||
|
//
|
||||||
|
var tokenHelper = context.RequestServices.GetRequiredService<TokenHelper>();
|
||||||
|
var configService = context.RequestServices.GetRequiredService<ConfigService<AppConfiguration>>();
|
||||||
|
|
||||||
|
User? user = null;
|
||||||
|
|
||||||
|
if (!await tokenHelper.IsValidAccessToken(accessToken, configService.Get().Authentication.MlAccessSecret,
|
||||||
|
data =>
|
||||||
|
{
|
||||||
|
if (!data.TryGetValue("userId", out var userIdStr))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!int.TryParse(userIdStr, out var userId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var userRepo = context.RequestServices.GetRequiredService<DatabaseRepository<User>>();
|
||||||
|
|
||||||
|
user = userRepo.Get().FirstOrDefault(x => x.Id == userId);
|
||||||
|
|
||||||
|
return user != null;
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(user == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Validate external access
|
||||||
|
if (DateTime.UtcNow > user.RefreshTimestamp)
|
||||||
|
{
|
||||||
|
var tokenConsumer = new TokenConsumer(user.AccessToken, user.RefreshToken, user.RefreshTimestamp,
|
||||||
|
async refreshToken =>
|
||||||
|
{
|
||||||
|
var oauth2Service = context.RequestServices.GetRequiredService<OAuth2Service>();
|
||||||
|
|
||||||
|
var accessData = await oauth2Service.RefreshAccess(refreshToken);
|
||||||
|
|
||||||
|
user.AccessToken = accessData.AccessToken;
|
||||||
|
user.RefreshToken = accessData.RefreshToken;
|
||||||
|
user.RefreshTimestamp = DateTime.UtcNow.AddSeconds(accessData.ExpiresIn);
|
||||||
|
|
||||||
|
var userRepo = context.RequestServices.GetRequiredService<DatabaseRepository<User>>();
|
||||||
|
|
||||||
|
userRepo.Update(user);
|
||||||
|
|
||||||
|
return new TokenPair()
|
||||||
|
{
|
||||||
|
AccessToken = user.AccessToken,
|
||||||
|
RefreshToken = user.RefreshToken
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await tokenConsumer.GetAccessToken();
|
||||||
|
//TODO: API CALL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load permissions, handle empty values
|
||||||
|
var permissions = JsonSerializer.Deserialize<string[]>(
|
||||||
|
string.IsNullOrEmpty(user.PermissionsJson) ? "[]" : user.PermissionsJson
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
// Save permission state
|
||||||
|
context.User = new PermClaimsPrinciple(permissions, user);
|
||||||
|
|
||||||
|
/*
|
||||||
string? token = null;
|
string? token = null;
|
||||||
|
|
||||||
// Cookie for Moonlight.Client
|
// Cookie for Moonlight.Client
|
||||||
@@ -62,7 +140,7 @@ public class AuthenticationMiddleware
|
|||||||
if (token.Count(x => x == '.') == 2) // JWT only has two dots
|
if (token.Count(x => x == '.') == 2) // JWT only has two dots
|
||||||
await AuthenticateUser(context, token);
|
await AuthenticateUser(context, token);
|
||||||
else
|
else
|
||||||
await AuthenticateApiKey(context, token);
|
await AuthenticateApiKey(context, token);*/
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AuthenticateUser(HttpContext context, string jwt)
|
private async Task AuthenticateUser(HttpContext context, string jwt)
|
||||||
@@ -71,12 +149,12 @@ public class AuthenticationMiddleware
|
|||||||
var configService = context.RequestServices.GetRequiredService<ConfigService<AppConfiguration>>();
|
var configService = context.RequestServices.GetRequiredService<ConfigService<AppConfiguration>>();
|
||||||
var secret = configService.Get().Authentication.Secret;
|
var secret = configService.Get().Authentication.Secret;
|
||||||
|
|
||||||
if(!await jwtHelper.Validate(secret, jwt, "login"))
|
if (!await jwtHelper.Validate(secret, jwt, "login"))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var data = await jwtHelper.Decode(secret, jwt);
|
var data = await jwtHelper.Decode(secret, jwt);
|
||||||
|
|
||||||
if(!data.TryGetValue("iat", out var issuedAtString) || !data.TryGetValue("userId", out var userIdString))
|
if (!data.TryGetValue("iat", out var issuedAtString) || !data.TryGetValue("userId", out var userIdString))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var userId = int.Parse(userIdString);
|
var userId = int.Parse(userIdString);
|
||||||
@@ -85,11 +163,11 @@ public class AuthenticationMiddleware
|
|||||||
var userRepo = context.RequestServices.GetRequiredService<DatabaseRepository<User>>();
|
var userRepo = context.RequestServices.GetRequiredService<DatabaseRepository<User>>();
|
||||||
var user = userRepo.Get().FirstOrDefault(x => x.Id == userId);
|
var user = userRepo.Get().FirstOrDefault(x => x.Id == userId);
|
||||||
|
|
||||||
if(user == null)
|
if (user == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Check if token is in the past
|
// Check if token is in the past
|
||||||
if(user.TokenValidTimestamp > issuedAt)
|
if (user.TokenValidTimestamp > issuedAt)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Load permissions, handle empty values
|
// Load permissions, handle empty values
|
||||||
@@ -103,6 +181,5 @@ public class AuthenticationMiddleware
|
|||||||
|
|
||||||
private async Task AuthenticateApiKey(HttpContext context, string apiKey)
|
private async Task AuthenticateApiKey(HttpContext context, string apiKey)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="MoonCore" Version="1.5.8" />
|
<PackageReference Include="MoonCore" Version="1.5.9" />
|
||||||
<PackageReference Include="MoonCore.Extended" Version="1.0.7" />
|
<PackageReference Include="MoonCore.Extended" Version="1.0.8" />
|
||||||
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.0" />
|
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.0" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
||||||
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
||||||
<PackageReference Include="MoonCore.Blazor.Tailwind" Version="1.0.5" />
|
<PackageReference Include="MoonCore.Blazor.Tailwind" Version="1.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using MoonCore.Extended.Abstractions;
|
using MoonCore.Extended.Abstractions;
|
||||||
|
using MoonCore.Extended.Extensions;
|
||||||
using MoonCore.Extended.Helpers;
|
using MoonCore.Extended.Helpers;
|
||||||
using MoonCore.Extensions;
|
using MoonCore.Extensions;
|
||||||
using MoonCore.Helpers;
|
using MoonCore.Helpers;
|
||||||
@@ -20,6 +21,8 @@ var configService = new ConfigService<AppConfiguration>(
|
|||||||
PathBuilder.File("storage", "config.json")
|
PathBuilder.File("storage", "config.json")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var config = configService.Get();
|
||||||
|
|
||||||
ApplicationStateHelper.SetConfiguration(configService);
|
ApplicationStateHelper.SetConfiguration(configService);
|
||||||
|
|
||||||
// Build pre run logger
|
// Build pre run logger
|
||||||
@@ -79,6 +82,52 @@ builder.Services.AddSingleton(configService);
|
|||||||
builder.Services.AddSingleton<JwtHelper>();
|
builder.Services.AddSingleton<JwtHelper>();
|
||||||
builder.Services.AutoAddServices<Program>();
|
builder.Services.AutoAddServices<Program>();
|
||||||
|
|
||||||
|
// OAuth2
|
||||||
|
builder.Services.AddSingleton<TokenHelper>();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddOAuth2Consumer(configuration =>
|
||||||
|
{
|
||||||
|
configuration.ClientId = config.Authentication.ClientId;
|
||||||
|
configuration.ClientSecret = config.Authentication.ClientSecret;
|
||||||
|
configuration.AuthorizationRedirect =
|
||||||
|
config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle";
|
||||||
|
|
||||||
|
configuration.AccessEndpoint = config.Authentication.AccessEndpoint ?? $"{config.PublicUrl}/oauth2/access";
|
||||||
|
configuration.RefreshEndpoint = config.Authentication.RefreshEndpoint ?? $"{config.PublicUrl}/oauth2/refresh";
|
||||||
|
|
||||||
|
if (config.Authentication.UseLocalOAuth2Service)
|
||||||
|
{
|
||||||
|
configuration.AuthorizationEndpoint = config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/oauth2/authorize";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(config.Authentication.AuthorizationUri == null)
|
||||||
|
logger.LogWarning("The 'AuthorizationUri' for the oauth2 client is not set. If you want to use an external oauth2 provider, you need to specify this url. If you want to use the local oauth2 service, set 'UseLocalOAuth2Service' to true");
|
||||||
|
|
||||||
|
configuration.AuthorizationEndpoint = config.Authentication.AuthorizationUri!;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.Authentication.UseLocalOAuth2Service)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Using local oauth2 provider");
|
||||||
|
|
||||||
|
builder.Services.AddOAuth2Provider(configuration =>
|
||||||
|
{
|
||||||
|
configuration.AccessSecret = config.Authentication.AccessSecret;
|
||||||
|
configuration.RefreshSecret = config.Authentication.RefreshSecret;
|
||||||
|
|
||||||
|
configuration.ClientId = config.Authentication.ClientId;
|
||||||
|
configuration.ClientId = config.Authentication.ClientSecret;
|
||||||
|
configuration.CodeSecret = config.Authentication.CodeSecret;
|
||||||
|
configuration.AuthorizationRedirect =
|
||||||
|
config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle";
|
||||||
|
configuration.AccessTokenDuration = 60;
|
||||||
|
configuration.RefreshTokenDuration = 3600;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
var databaseHelper = new DatabaseHelper(
|
var databaseHelper = new DatabaseHelper(
|
||||||
loggerFactory.CreateLogger<DatabaseHelper>()
|
loggerFactory.CreateLogger<DatabaseHelper>()
|
||||||
@@ -110,7 +159,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
await databaseHelper.EnsureMigrated(scope.ServiceProvider);
|
await databaseHelper.EnsureMigrated(scope.ServiceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseWebAssemblyDebugging();
|
app.UseWebAssemblyDebugging();
|
||||||
|
|
||||||
app.UseBlazorFrameworkFiles();
|
app.UseBlazorFrameworkFiles();
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.6"/>
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.6"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.6" PrivateAssets="all"/>
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.6" PrivateAssets="all"/>
|
||||||
<PackageReference Include="MoonCore" Version="1.5.8" />
|
<PackageReference Include="MoonCore" Version="1.5.9" />
|
||||||
<PackageReference Include="MoonCore.Blazor" Version="1.2.1" />
|
<PackageReference Include="MoonCore.Blazor" Version="1.2.1" />
|
||||||
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.0" />
|
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.0" />
|
||||||
|
<PackageReference Include="MoonCore.Blazor.Tailwind" Version="1.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -29,10 +30,4 @@
|
|||||||
<Folder Include="wwwroot\css\" />
|
<Folder Include="wwwroot\css\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="MoonCore.Blazor.Tailwind">
|
|
||||||
<HintPath>..\..\..\..\GitHub\Marcel-Baumgartner\MoonCore\MoonCore\MoonCore.Blazor.Tailwind\bin\Debug\net8.0\MoonCore.Blazor.Tailwind.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,141 +1,24 @@
|
|||||||
@page "/auth/register"
|
|
||||||
@page "/auth/login"
|
|
||||||
|
|
||||||
@using MoonCore.Blazor.Tailwind.Forms.Components
|
|
||||||
@using MoonCore.Helpers
|
@using MoonCore.Helpers
|
||||||
@using Moonlight.Client.Services
|
|
||||||
@using Moonlight.Client.UI.Layouts
|
|
||||||
@using Moonlight.Shared.Http.Requests.Auth
|
|
||||||
@using Moonlight.Shared.Http.Responses.Auth
|
@using Moonlight.Shared.Http.Responses.Auth
|
||||||
|
|
||||||
@implements IDisposable
|
|
||||||
|
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject HttpApiClient ApiClient
|
@inject HttpApiClient HttpApiClient
|
||||||
@inject IdentityService IdentityService
|
|
||||||
|
|
||||||
@{
|
|
||||||
var url = new Uri(Navigation.Uri);
|
|
||||||
var isRegister = url.LocalPath.StartsWith("/auth/register");
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="h-full w-full min-h-[100dvh] flex items-center justify-center px-5 md:px-0">
|
|
||||||
|
|
||||||
@if (isRegister)
|
|
||||||
{
|
|
||||||
<div class="card card-body w-full max-w-lg">
|
|
||||||
<div class="sm:mx-auto sm:w-full sm:max-w-sm mb-5">
|
|
||||||
<img class="mx-auto h-16 w-auto" src="/logolong.webp" alt="Moonlight">
|
|
||||||
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-100">Register your account</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HandleForm @ref="RegisterForm" Model="RegisterRequest" OnValidSubmit="OnSubmitRegister">
|
|
||||||
<GeneratedForm Model="RegisterRequest" OnConfigure="OnConfigureRegister" Gap="gap-x-6 gap-y-3"/>
|
|
||||||
</HandleForm>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col justify-center">
|
|
||||||
<WButton OnClick="_ => RegisterForm.Submit()" CssClasses="btn btn-primary">Register</WButton>
|
|
||||||
<p class="mt-3 text-center text-sm text-gray-500">
|
|
||||||
Already registered?
|
|
||||||
<a href="/auth/login" class="font-semibold leading-6 text-indigo-600 hover:text-indigo-500">Login</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="card card-body w-full max-w-lg">
|
|
||||||
<div class="sm:mx-auto sm:w-full sm:max-w-sm mb-5">
|
|
||||||
<img class="mx-auto h-16 w-auto" src="/logolong.webp" alt="Moonlight">
|
|
||||||
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-100">Sign in to your account</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HandleForm @ref="LoginForm" Model="LoginRequest" OnValidSubmit="OnSubmitLogin">
|
|
||||||
<GeneratedForm Model="LoginRequest" OnConfigure="OnConfigureLogin" Gap="gap-x-6 gap-y-3"/>
|
|
||||||
</HandleForm>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col justify-center">
|
|
||||||
<WButton OnClick="_ => LoginForm.Submit()" CssClasses="btn btn-primary">Login</WButton>
|
|
||||||
<p class="mt-3 text-center text-sm text-gray-500">
|
|
||||||
Need an account registered?
|
|
||||||
<a href="/auth/register" class="font-semibold leading-6 text-indigo-600 hover:text-indigo-500">Register</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<WButton OnClick="StartAuth" CssClasses="btn btn-primary">Authenticate</WButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code
|
@code
|
||||||
{
|
{
|
||||||
[CascadingParameter] public MainLayout Layout { get; set; }
|
private async Task StartAuth(WButton _)
|
||||||
|
|
||||||
// Page change handling
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
{
|
||||||
Navigation.LocationChanged += NavigationOnLocationChanged;
|
var authStartData = await HttpApiClient.GetJson<AuthStartResponse>("api/auth/start");
|
||||||
}
|
|
||||||
|
|
||||||
private async void NavigationOnLocationChanged(object? sender, LocationChangedEventArgs e)
|
var uri = authStartData.Endpoint
|
||||||
=> await InvokeAsync(StateHasChanged);
|
+ $"?client_id={authStartData.ClientId}" +
|
||||||
|
$"&redirect_uri={authStartData.RedirectUri}" +
|
||||||
|
$"&response_type=code";
|
||||||
|
|
||||||
public void Dispose()
|
Navigation.NavigateTo(uri, true);
|
||||||
{
|
|
||||||
Navigation.LocationChanged -= NavigationOnLocationChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register
|
|
||||||
private RegisterRequest RegisterRequest = new();
|
|
||||||
private HandleForm RegisterForm;
|
|
||||||
|
|
||||||
private async Task OnSubmitRegister()
|
|
||||||
{
|
|
||||||
var response = await ApiClient.PostJson<RegisterResponse>("api/auth/register", RegisterRequest);
|
|
||||||
await IdentityService.Login(response.Token);
|
|
||||||
|
|
||||||
await HandleAfterAuthPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnConfigureRegister(FormConfiguration<RegisterRequest> configuration)
|
|
||||||
{
|
|
||||||
configuration.WithField(x => x.Username, fieldConfiguration => { fieldConfiguration.Columns = 6; });
|
|
||||||
|
|
||||||
configuration.WithField(x => x.Email, fieldConfiguration => { fieldConfiguration.Columns = 6; });
|
|
||||||
|
|
||||||
configuration
|
|
||||||
.WithField(x => x.Password, fieldConfiguration => { fieldConfiguration.Columns = 6; })
|
|
||||||
.WithComponent<StringComponent>(component => { component.Type = "password"; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login
|
|
||||||
private LoginRequest LoginRequest = new();
|
|
||||||
private HandleForm LoginForm;
|
|
||||||
|
|
||||||
private async Task OnSubmitLogin()
|
|
||||||
{
|
|
||||||
var response = await ApiClient.PostJson<LoginResponse>("api/auth/login", LoginRequest);
|
|
||||||
await IdentityService.Login(response.Token);
|
|
||||||
|
|
||||||
await HandleAfterAuthPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnConfigureLogin(FormConfiguration<LoginRequest> configuration)
|
|
||||||
{
|
|
||||||
configuration.WithField(x => x.Email, fieldConfiguration => { fieldConfiguration.Columns = 6; });
|
|
||||||
|
|
||||||
configuration
|
|
||||||
.WithField(x => x.Password, fieldConfiguration => { fieldConfiguration.Columns = 6; })
|
|
||||||
.WithComponent<StringComponent>(component => { component.Type = "password"; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation handling
|
|
||||||
private async Task HandleAfterAuthPage()
|
|
||||||
{
|
|
||||||
var url = new Uri(Navigation.Uri);
|
|
||||||
|
|
||||||
if (url.LocalPath.StartsWith("/auth/login") || url.LocalPath.StartsWith("/auth/register"))
|
|
||||||
Navigation.NavigateTo("/");
|
|
||||||
|
|
||||||
await Layout.Load();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Moonlight.Shared.Http.Responses.Auth;
|
||||||
|
|
||||||
|
public class AuthStartResponse
|
||||||
|
{
|
||||||
|
public string Endpoint { get; set; }
|
||||||
|
public string ClientId { get; set; }
|
||||||
|
public string RedirectUri { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user