Added all necessary api endpoints for OpenID
This commit is contained in:
@@ -74,6 +74,7 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</wpf:ResourceDictionary>
|
||||
@@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace HopFrame.Api.Controller;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/authentication")]
|
||||
public class SecurityController(IAuthLogic auth) : ControllerBase {
|
||||
[Route("api/v1/auth")]
|
||||
public class AuthController(IAuthLogic auth) : ControllerBase {
|
||||
|
||||
[HttpPut("login")]
|
||||
public async Task<ActionResult<SingleValueResult<string>>> Login([FromBody] UserLogin login) {
|
||||
16
src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs
Normal file
16
src/HopFrame.Api/Controller/HopFrameFeatureProvider.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
|
||||
namespace HopFrame.Api.Controller;
|
||||
|
||||
public class HopFrameFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider {
|
||||
protected override bool IsController(TypeInfo typeInfo) {
|
||||
if (typeInfo.Namespace != typeof(HopFrameFeatureProvider).Namespace)
|
||||
return base.IsController(typeInfo);
|
||||
|
||||
if (controllerTypes.All(c => c.Name != typeInfo.Name))
|
||||
return false;
|
||||
|
||||
return base.IsController(typeInfo);
|
||||
}
|
||||
}
|
||||
79
src/HopFrame.Api/Controller/OpenIdController.cs
Normal file
79
src/HopFrame.Api/Controller/OpenIdController.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using HopFrame.Api.Models;
|
||||
using HopFrame.Security.Authentication.OpenID;
|
||||
using HopFrame.Security.Authentication.OpenID.Options;
|
||||
using HopFrame.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HopFrame.Api.Controller;
|
||||
|
||||
[ApiController, Route("api/v1/openid")]
|
||||
public class OpenIdController(IOpenIdAccessor accessor, IOptions<OpenIdOptions> options) : ControllerBase {
|
||||
|
||||
[HttpGet("redirect")]
|
||||
public async Task<IActionResult> RedirectToProvider([FromQuery] string redirectAfter, [FromQuery] int performRedirect = 1) {
|
||||
var uri = await accessor.ConstructAuthUri(redirectAfter);
|
||||
|
||||
if (performRedirect == 1) {
|
||||
return Redirect(uri);
|
||||
}
|
||||
|
||||
return Ok(new SingleValueResult<string>(uri));
|
||||
}
|
||||
|
||||
[HttpGet("callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state) {
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
return BadRequest("Authorization code is missing");
|
||||
}
|
||||
|
||||
var token = await accessor.RequestToken(code);
|
||||
|
||||
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
|
||||
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
|
||||
HttpOnly = false,
|
||||
Secure = true
|
||||
});
|
||||
Response.Cookies.Append(ITokenContext.RefreshTokenType, token.RefreshToken, new CookieOptions {
|
||||
MaxAge = options.Value.RefreshToken.ConstructTimeSpan,
|
||||
HttpOnly = false,
|
||||
Secure = true
|
||||
});
|
||||
|
||||
if (string.IsNullOrEmpty(state)) {
|
||||
return Ok(new SingleValueResult<string>(token.AccessToken));
|
||||
}
|
||||
|
||||
return Redirect(state.Replace("{token}", token.AccessToken));
|
||||
}
|
||||
|
||||
[HttpGet("refresh")]
|
||||
public async Task<IActionResult> Refresh() {
|
||||
var refreshToken = Request.Cookies[ITokenContext.RefreshTokenType];
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken))
|
||||
return BadRequest("Refresh token not provided");
|
||||
|
||||
var token = await accessor.RefreshAccessToken(refreshToken);
|
||||
|
||||
if (token is null)
|
||||
return NotFound("Refresh token not valid");
|
||||
|
||||
Response.Cookies.Append(ITokenContext.AccessTokenType, token.AccessToken, new CookieOptions {
|
||||
MaxAge = TimeSpan.FromSeconds(token.ExpiresIn),
|
||||
HttpOnly = false,
|
||||
Secure = true
|
||||
});
|
||||
|
||||
return Ok(new SingleValueResult<string>(token.AccessToken));
|
||||
}
|
||||
|
||||
[HttpDelete("logout")]
|
||||
public IActionResult Logout() {
|
||||
Response.Cookies.Delete(ITokenContext.RefreshTokenType);
|
||||
Response.Cookies.Delete(ITokenContext.AccessTokenType);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -83,4 +83,4 @@ public static class MvcExtensions {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,13 @@ public static class ServiceCollectionExtensions {
|
||||
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
|
||||
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
|
||||
public static void AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
|
||||
services.AddMvcCore().UseSpecificControllers(typeof(SecurityController));
|
||||
var controllers = new List<Type> { typeof(AuthController) };
|
||||
|
||||
if (configuration.GetValue<bool>("HopFrame:Authentication:OpenID:Enabled"))
|
||||
controllers.Add(typeof(OpenIdController));
|
||||
|
||||
AddHopFrameNoEndpoints<TDbContext>(services, configuration);
|
||||
services.AddMvcCore().UseSpecificControllers(controllers.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -30,6 +35,11 @@ public static class ServiceCollectionExtensions {
|
||||
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
|
||||
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
|
||||
public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
|
||||
services.AddMvcCore().ConfigureApplicationPartManager(manager => {
|
||||
var endpoints = manager.ApplicationParts.SingleOrDefault(p => p.Name == typeof(ServiceCollectionExtensions).Namespace!.Replace(".Extensions", ""));
|
||||
manager.ApplicationParts.Remove(endpoints);
|
||||
});
|
||||
|
||||
services.AddHopFrameRepositories<TDbContext>();
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
services.AddScoped<IAuthLogic, AuthLogic>();
|
||||
|
||||
@@ -101,9 +101,7 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
|
||||
var refreshToken = accessor.HttpContext?.Request.Cookies[ITokenContext.RefreshTokenType];
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||
return LogicResult.Conflict("access or refresh token not provided");
|
||||
|
||||
await tokens.DeleteUserTokens(tokenContext.User);
|
||||
await tokens.DeleteUserTokens(tokenContext.User);
|
||||
|
||||
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.RefreshTokenType);
|
||||
accessor.HttpContext?.Response.Cookies.Delete(ITokenContext.AccessTokenType);
|
||||
|
||||
@@ -24,6 +24,7 @@ public static class HopFrameAuthenticationExtensions {
|
||||
service.AddScoped<ITokenContext, TokenContextImplementor>();
|
||||
|
||||
service.AddHttpClient();
|
||||
service.AddMemoryCache();
|
||||
service.AddScoped<IOpenIdAccessor, OpenIdAccessor>();
|
||||
|
||||
service.AddOptionsFromConfiguration<HopFrameAuthenticationOptions>(configuration);
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace HopFrame.Security.Authentication;
|
||||
public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
|
||||
public override string Position { get; } = "HopFrame:Authentication";
|
||||
|
||||
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds);
|
||||
public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.Seconds);
|
||||
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : AccessToken.ConstructTimeSpan;
|
||||
public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : RefreshToken.ConstructTimeSpan;
|
||||
|
||||
public TokenTime AccessToken { get; set; }
|
||||
public TokenTime RefreshToken { get; set; }
|
||||
@@ -16,5 +16,7 @@ public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
|
||||
public int Hours { get; set; }
|
||||
public int Minutes { get; set; }
|
||||
public int Seconds { get; set; }
|
||||
|
||||
public TimeSpan ConstructTimeSpan => new(Days, Hours, Minutes, Seconds);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ public interface IOpenIdAccessor {
|
||||
Task<OpenIdToken> RequestToken(string code);
|
||||
Task<string> ConstructAuthUri(string state = null);
|
||||
Task<OpenIdIntrospection> InspectToken(string token);
|
||||
Task<OpenIdToken> RefreshAccessToken(string refreshToken);
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using HopFrame.Security.Authentication.OpenID.Models;
|
||||
using HopFrame.Security.Authentication.OpenID.Options;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HopFrame.Security.Authentication.OpenID.Implementation;
|
||||
|
||||
internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdOptions> options) : IOpenIdAccessor {
|
||||
internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdOptions> options, IHttpContextAccessor accessor, IMemoryCache cache) : IOpenIdAccessor {
|
||||
private const string DefaultCallbackEndpoint = "api/v1/openid/callback";
|
||||
|
||||
private const string ConfigurationCacheKey = "HopFrame:OpenID:Configuration";
|
||||
private const string AuthCodeCacheKey = "HopFrame:OpenID:Code:";
|
||||
private const string TokenCacheKey = "HopFrame:OpenID:Token:";
|
||||
|
||||
public async Task<OpenIdConfiguration> LoadConfiguration() {
|
||||
if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled && cache.TryGetValue(ConfigurationCacheKey, out object cachedConfiguration)) {
|
||||
return cachedConfiguration as OpenIdConfiguration;
|
||||
}
|
||||
|
||||
var client = clientFactory.CreateClient();
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, Path.Combine(options.Value.Issuer, ".well-known/openid-configuration"));
|
||||
var response = await client.SendAsync(request);
|
||||
@@ -14,10 +26,22 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return await JsonSerializer.DeserializeAsync<OpenIdConfiguration>(await response.Content.ReadAsStreamAsync());
|
||||
var config = await JsonSerializer.DeserializeAsync<OpenIdConfiguration>(await response.Content.ReadAsStreamAsync());
|
||||
|
||||
if (options.Value.Cache.Enabled && options.Value.Cache.Configuration.Enabled)
|
||||
cache.Set(ConfigurationCacheKey, config, options.Value.Cache.Configuration.TTL.ConstructTimeSpan);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<OpenIdToken> RequestToken(string code) {
|
||||
if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled && cache.TryGetValue(AuthCodeCacheKey + code, out object cachedToken)) {
|
||||
return cachedToken as OpenIdToken;
|
||||
}
|
||||
|
||||
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
|
||||
var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}";
|
||||
|
||||
var configuration = await LoadConfiguration();
|
||||
|
||||
var client = clientFactory.CreateClient();
|
||||
@@ -25,7 +49,7 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", options.Value.Callback },
|
||||
{ "redirect_uri", callback },
|
||||
{ "client_id", options.Value.ClientId },
|
||||
{ "client_secret", options.Value.ClientSecret }
|
||||
})
|
||||
@@ -35,15 +59,27 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
|
||||
var token = await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
|
||||
|
||||
if (options.Value.Cache.Enabled && options.Value.Cache.Auth.Enabled)
|
||||
cache.Set(AuthCodeCacheKey + code, token, options.Value.Cache.Auth.TTL.ConstructTimeSpan);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<string> ConstructAuthUri(string state = null) {
|
||||
var protocol = accessor.HttpContext!.Request.IsHttps ? "https" : "http";
|
||||
var callback = options.Value.Callback ?? $"{protocol}://{accessor.HttpContext!.Request.Host.Value}/{DefaultCallbackEndpoint}";
|
||||
|
||||
var configuration = await LoadConfiguration();
|
||||
return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={options.Value.Callback}&scope=openid%20profile%20email&state={state}";
|
||||
return $"{configuration.AuthorizationEndpoint}?response_type=code&client_id={options.Value.ClientId}&redirect_uri={callback}&scope=openid%20profile%20email%20offline_access&state={state}";
|
||||
}
|
||||
|
||||
public async Task<OpenIdIntrospection> InspectToken(string token) {
|
||||
if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled && cache.TryGetValue(TokenCacheKey + token, out object cachedToken)) {
|
||||
return cachedToken as OpenIdIntrospection;
|
||||
}
|
||||
|
||||
var configuration = await LoadConfiguration();
|
||||
|
||||
var client = clientFactory.CreateClient();
|
||||
@@ -59,6 +95,31 @@ internal class OpenIdAccessor(IHttpClientFactory clientFactory, IOptions<OpenIdO
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return await JsonSerializer.DeserializeAsync<OpenIdIntrospection>(await response.Content.ReadAsStreamAsync());
|
||||
var introspection = await JsonSerializer.DeserializeAsync<OpenIdIntrospection>(await response.Content.ReadAsStreamAsync());
|
||||
|
||||
if (options.Value.Cache.Enabled && options.Value.Cache.Inspection.Enabled)
|
||||
cache.Set(TokenCacheKey + token, introspection, options.Value.Cache.Inspection.TTL.ConstructTimeSpan);
|
||||
|
||||
return introspection;
|
||||
}
|
||||
|
||||
public async Task<OpenIdToken> RefreshAccessToken(string refreshToken) {
|
||||
var configuration = await LoadConfiguration();
|
||||
|
||||
var client = clientFactory.CreateClient();
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint) {
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
|
||||
{ "grant_type", "refresh_token" },
|
||||
{ "refresh_token", refreshToken },
|
||||
{ "client_id", options.Value.ClientId },
|
||||
{ "client_secret", options.Value.ClientSecret }
|
||||
})
|
||||
};
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return await JsonSerializer.DeserializeAsync<OpenIdToken>(await response.Content.ReadAsStreamAsync());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ namespace HopFrame.Security.Authentication.OpenID.Models;
|
||||
public sealed class OpenIdToken {
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; }
|
||||
|
||||
@@ -12,4 +12,42 @@ public sealed class OpenIdOptions : OptionsFromConfiguration {
|
||||
public string ClientId { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
public string Callback { get; set; }
|
||||
|
||||
public HopFrameAuthenticationOptions.TokenTime RefreshToken { get; set; } = new() {
|
||||
Days = 30
|
||||
};
|
||||
|
||||
public CachingOptions Cache { get; set; } = new() {
|
||||
Enabled = true,
|
||||
Configuration = new() {
|
||||
Enabled = true,
|
||||
TTL = new() {
|
||||
Minutes = 10
|
||||
}
|
||||
},
|
||||
Auth = new() {
|
||||
Enabled = true,
|
||||
TTL = new() {
|
||||
Seconds = 30
|
||||
}
|
||||
},
|
||||
Inspection = new() {
|
||||
Enabled = true,
|
||||
TTL = new() {
|
||||
Minutes = 2
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public class CachingTypeOptions {
|
||||
public bool Enabled { get; set; }
|
||||
public HopFrameAuthenticationOptions.TokenTime TTL { get; set; }
|
||||
}
|
||||
|
||||
public class CachingOptions {
|
||||
public bool Enabled { get; set; }
|
||||
public CachingTypeOptions Configuration { get; set; }
|
||||
public CachingTypeOptions Auth { get; set; }
|
||||
public CachingTypeOptions Inspection { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@ namespace HopFrame.Testing.Api.Controllers;
|
||||
public class AuthController(IOpenIdAccessor accessor) : Controller {
|
||||
|
||||
[HttpGet("auth/callback")]
|
||||
public async Task<ActionResult<string>> Callback([FromQuery] string code, [FromQuery] string state) {
|
||||
public async Task<ActionResult> Callback([FromQuery] string code, [FromQuery] string state) {
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
return BadRequest("Authorization code is missing");
|
||||
}
|
||||
|
||||
var token = await accessor.RequestToken(code);
|
||||
return Ok(token.AccessToken);
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
[HttpGet("auth")]
|
||||
|
||||
@@ -66,5 +66,11 @@ public class TestController(ITokenContext userContext, DatabaseContext context,
|
||||
var token = await tokens.GetToken(tokenId);
|
||||
await tokens.DeleteToken(token);
|
||||
}
|
||||
|
||||
[HttpGet("url")]
|
||||
public async Task<ActionResult<SingleValueResult<string>>> GetUrl() {
|
||||
var protocol = Request.IsHttps ? "https" : "http";
|
||||
return Ok($"{protocol}://{Request.Host.Value}/auth/callback");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user