add Jwt authorization support for for API

This commit is contained in:
Shaun Walker 2022-03-28 21:51:55 -04:00
parent c8129607e8
commit a97af42e4b
16 changed files with 282 additions and 40 deletions

View File

@ -129,6 +129,17 @@ else
</div>
</div>
</Section>
<Section Name="Cookie" Heading="Cookie Settings" ResourceKey="CookieSettings">
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="cookietype" HelpText="Cookies are managed per domain by default. However you can also choose to have distinct cookies for each site." ResourceKey="CookieType">Cookie Type:</Label>
<div class="col-sm-9">
<select id="cookietype" class="form-select" @bind="@_cookietype">
<option value="domain">@Localizer["Domain"]</option>
<option value="site">@Localizer["Site"]</option>
</select>
</div>
</div>
</Section>
<Section Name="ExternalLogin" Heading="External Login Settings" ResourceKey="ExternalLoginSettings">
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="providertype" HelpText="Select the external login provider type" ResourceKey="ProviderType">Provider Type:</Label>
@ -255,6 +266,23 @@ else
</div>
}
</Section>
<Section Name="Token" Heading="Token Settings" ResourceKey="TokenSettings">
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="secret" HelpText="If you want to want to provide API access, please specify a secret which will be used to encrypt your tokens. The secret should be 16 characters or more to ensure optimal security. Please note that if you change this secret, all existing tokens will become invalid and will need to be regenerated." ResourceKey="Secret">Site Secret:</Label>
<div class="col-sm-9">
<input id="secret" class="form-control" @bind="@_secret" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="token" HelpText="Select the Create Token button to generate an access token. The token will be valid for 1 year. Be sure to save this token in a safe place as you will not be able to view it in the future." ResourceKey="Token">Access Token:</Label>
<div class="col-sm-9">
<div class="input-group">
<input id="token" class="form-control" @bind="@_token" />
<button type="button" class="btn btn-secondary" @onclick="@CreateToken">@Localizer["CreateToken"]</button>
</div>
</div>
</div>
</Section>
</div>
<br />
<button type="button" class="btn btn-success" @onclick="SaveSiteSettings">@SharedLocalizer["Save"]</button>
@ -277,6 +305,8 @@ else
private string _maximumfailures;
private string _lockoutduration;
private string _cookietype;
private string _providertype;
private string _providername;
private string _authority;
@ -294,6 +324,9 @@ else
private string _createusers;
private string _allowsitelogin;
private string _secret;
private string _token;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Admin;
protected override async Task OnInitializedAsync()
@ -311,9 +344,12 @@ else
_requireupper = SettingService.GetSetting(settings, "IdentityOptions:Password:RequireUppercase", "true");
_requirelower = SettingService.GetSetting(settings, "IdentityOptions:Password:RequireLowercase", "true");
_requirepunctuation = SettingService.GetSetting(settings, "IdentityOptions:Password:RequireNonAlphanumeric", "true");
_maximumfailures = SettingService.GetSetting(settings, "IdentityOptions:Lockout:MaxFailedAccessAttempts", "5");
_lockoutduration = TimeSpan.Parse(SettingService.GetSetting(settings, "IdentityOptions:Lockout:DefaultLockoutTimeSpan", "00:05:00")).TotalMinutes.ToString();
_cookietype = SettingService.GetSetting(settings, "CookieOptions:CookieType", "domain");
_providertype = SettingService.GetSetting(settings, "ExternalLogin:ProviderType", "");
_providername = SettingService.GetSetting(settings, "ExternalLogin:ProviderName", "");
_authority = SettingService.GetSetting(settings, "ExternalLogin:Authority", "");
@ -330,6 +366,8 @@ else
_domainfilter = SettingService.GetSetting(settings, "ExternalLogin:DomainFilter", "");
_createusers = SettingService.GetSetting(settings, "ExternalLogin:CreateUsers", "true");
_allowsitelogin = SettingService.GetSetting(settings, "ExternalLogin:AllowSiteLogin", "true");
_secret = SettingService.GetSetting(settings, "JwtOptions:Secret", "");
}
private List<UserRole> Search(string search)
@ -406,9 +444,12 @@ else
settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireUppercase", _requireupper, true);
settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireLowercase", _requirelower, true);
settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireNonAlphanumeric", _requirepunctuation, true);
settings = SettingService.SetSetting(settings, "IdentityOptions:Lockout:MaxFailedAccessAttempts", _maximumfailures, true);
settings = SettingService.SetSetting(settings, "IdentityOptions:Lockout:DefaultLockoutTimeSpan", TimeSpan.FromMinutes(Convert.ToInt64(_lockoutduration)).ToString(), true);
settings = SettingService.SetSetting(settings, "CookieOptions:CookieType", _cookietype, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:ProviderType", _providertype, false);
settings = SettingService.SetSetting(settings, "ExternalLogin:ProviderName", _providername, false);
settings = SettingService.SetSetting(settings, "ExternalLogin:Authority", _authority, true);
@ -425,6 +466,9 @@ else
settings = SettingService.SetSetting(settings, "ExternalLogin:CreateUsers", _createusers, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:AllowSiteLogin", _allowsitelogin, false);
if (!string.IsNullOrEmpty(_secret) && _secret.Length < 16) _secret = (_secret + "????????????????").Substring(0, 16);
settings = SettingService.SetSetting(settings, "JwtOptions:Secret", _secret, true);
await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId);
await SettingService.ClearSiteSettingsCacheAsync(site.SiteId);
@ -451,4 +495,9 @@ else
_redirecturl = PageState.Uri.Scheme + "://" + PageState.Alias.Name + "/signin-" + _providertype;
StateHasChanged();
}
private async Task CreateToken()
{
_token = await UserService.GetTokenAsync();
}
}

View File

@ -104,5 +104,10 @@ namespace Oqtane.Services
/// <returns></returns>
Task<bool> ValidatePasswordAsync(string password);
/// <summary>
/// Get token for current user
/// </summary>
/// <returns></returns>
Task<string> GetTokenAsync();
}
}

View File

@ -79,5 +79,10 @@ namespace Oqtane.Services
{
return await GetJsonAsync<bool>($"{Apiurl}/validate/{WebUtility.UrlEncode(password)}");
}
public async Task<string> GetTokenAsync()
{
return await GetStringAsync($"{Apiurl}/token");
}
}
}

View File

@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace Oqtane.Controllers
{
@ -142,6 +143,8 @@ namespace Oqtane.Controllers
[Authorize(Roles = RoleNames.Admin)]
public void Clear(int id)
{
var cookieAuthenticationOptionsCache = new SiteOptionsCache<CookieAuthenticationOptions>(_aliasAccessor);
cookieAuthenticationOptionsCache.Clear();
var openIdConnectOptionsCache = new SiteOptionsCache<OpenIdConnectOptions>(_aliasAccessor);
openIdConnectOptionsCache.Clear();
var oAuthOptionsCache = new SiteOptionsCache<OAuthOptions>(_aliasAccessor);

View File

@ -14,6 +14,8 @@ using System.Net;
using Oqtane.Enums;
using Oqtane.Infrastructure;
using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Extensions;
namespace Oqtane.Controllers
{
@ -30,9 +32,10 @@ namespace Oqtane.Controllers
private readonly IFolderRepository _folders;
private readonly ISyncManager _syncManager;
private readonly ISiteRepository _sites;
private readonly IJwtManager _jwtManager;
private readonly ILogManager _logger;
public UserController(IUserRepository users, IRoleRepository roles, IUserRoleRepository userRoles, UserManager<IdentityUser> identityUserManager, SignInManager<IdentityUser> identitySignInManager, ITenantManager tenantManager, INotificationRepository notifications, IFolderRepository folders, ISyncManager syncManager, ISiteRepository sites, ILogManager logger)
public UserController(IUserRepository users, IRoleRepository roles, IUserRoleRepository userRoles, UserManager<IdentityUser> identityUserManager, SignInManager<IdentityUser> identitySignInManager, ITenantManager tenantManager, INotificationRepository notifications, IFolderRepository folders, ISyncManager syncManager, ISiteRepository sites, IJwtManager jwtManager, ILogManager logger)
{
_users = users;
_roles = roles;
@ -44,6 +47,7 @@ namespace Oqtane.Controllers
_notifications = notifications;
_syncManager = syncManager;
_sites = sites;
_jwtManager = jwtManager;
_logger = logger;
}
@ -516,6 +520,24 @@ namespace Oqtane.Controllers
return result.Succeeded;
}
// GET api/<controller>/token
[HttpGet("token")]
[Authorize(Roles = RoleNames.Admin)]
public string Token()
{
var token = "";
var user = _users.GetUser(User.Identity.Name);
if (user != null)
{
var secret = HttpContext.GetSiteSettings().GetValue("JwtOptions:Secret", "");
if (!string.IsNullOrEmpty(secret))
{
token = _jwtManager.GenerateToken(user, secret);
}
}
return token;
}
// GET api/<controller>/authenticate
[HttpGet("authenticate")]
public User Authenticate()

View File

@ -41,5 +41,8 @@ namespace Oqtane.Extensions
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder builder)
=> builder.UseMiddleware<TenantMiddleware>();
public static IApplicationBuilder UseJwtAuthorization(this IApplicationBuilder builder)
=> builder.UseMiddleware<JwtMiddleware>();
}
}

View File

@ -85,10 +85,12 @@ namespace Microsoft.Extensions.DependencyInjection
{
services.AddTransient<ITenantManager, TenantManager>();
services.AddTransient<IAliasAccessor, AliasAccessor>();
services.AddTransient<IModuleDefinitionRepository, ModuleDefinitionRepository>();
services.AddTransient<IThemeRepository, ThemeRepository>();
services.AddTransient<IUserPermissions, UserPermissions>();
services.AddTransient<ITenantResolver, TenantResolver>();
services.AddTransient<IJwtManager, JwtManager>();
services.AddTransient<IModuleDefinitionRepository, ModuleDefinitionRepository>();
services.AddTransient<IThemeRepository, ThemeRepository>();
services.AddTransient<IAliasRepository, AliasRepository>();
services.AddTransient<ITenantRepository, TenantRepository>();
services.AddTransient<ISiteRepository, SiteRepository>();
@ -115,6 +117,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddTransient<ILanguageRepository, LanguageRepository>();
services.AddTransient<IVisitorRepository, VisitorRepository>();
services.AddTransient<IUrlMappingRepository, UrlMappingRepository>();
// obsolete - replaced by ITenantManager
services.AddTransient<ITenantResolver, TenantResolver>();
@ -181,7 +184,7 @@ namespace Microsoft.Extensions.DependencyInjection
options.SignIn.RequireConfirmedPhoneNumber = false;
// User settings
options.User.RequireUniqueEmail = false;
options.User.RequireUniqueEmail = true;
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
});

View File

@ -11,13 +11,13 @@ using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Oqtane.Repository;
using System.Collections.Generic;
using Oqtane.Security;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication.OAuth;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace Oqtane.Extensions
{
@ -25,7 +25,21 @@ namespace Oqtane.Extensions
{
public static OqtaneSiteOptionsBuilder WithSiteAuthentication(this OqtaneSiteOptionsBuilder builder)
{
// site OpenIdConnect options
// site cookie authentication options
builder.AddSiteOptions<CookieAuthenticationOptions>((options, alias, sitesettings) =>
{
if (sitesettings.GetValue("CookieOptions:CookieType", "domain") == "domain")
{
options.Cookie.Name = ".AspNetCore.Identity.Application";
}
else
{
// use unique cookie name for site
options.Cookie.Name = ".AspNetCore.Identity.Application" + alias.SiteKey;
}
});
// site OpenId Connect options
builder.AddSiteOptions<OpenIdConnectOptions>((options, alias, sitesettings) =>
{
if (sitesettings.GetValue("ExternalLogin:ProviderType", "") == AuthenticationProviderTypes.OpenIDConnect)
@ -33,7 +47,7 @@ namespace Oqtane.Extensions
// default options
options.SignInScheme = Constants.AuthenticationScheme; // identity cookie
options.RequireHttpsMetadata = true;
options.SaveTokens = true;
options.SaveTokens = false;
options.GetClaimsFromUserInfoEndpoint = true;
options.CallbackPath = string.IsNullOrEmpty(alias.Path) ? "/signin-" + AuthenticationProviderTypes.OpenIDConnect : "/" + alias.Path + "/signin-" + AuthenticationProviderTypes.OpenIDConnect;
options.ResponseType = OpenIdConnectResponseType.Code; // authorization code flow
@ -62,7 +76,7 @@ namespace Oqtane.Extensions
}
});
// site OAuth2.0 options
// site OAuth 2.0 options
builder.AddSiteOptions<OAuthOptions>((options, alias, sitesettings) =>
{
if (sitesettings.GetValue("ExternalLogin:ProviderType", "") == AuthenticationProviderTypes.OAuth2)
@ -70,7 +84,7 @@ namespace Oqtane.Extensions
// default options
options.SignInScheme = Constants.AuthenticationScheme; // identity cookie
options.CallbackPath = string.IsNullOrEmpty(alias.Path) ? "/signin-" + AuthenticationProviderTypes.OAuth2 : "/" + alias.Path + "/signin-" + AuthenticationProviderTypes.OAuth2;
options.SaveTokens = true;
options.SaveTokens = false;
// site options
options.AuthorizationEndpoint = sitesettings.GetValue("ExternalLogin:AuthorizationUrl", "");
@ -264,11 +278,9 @@ namespace Oqtane.Extensions
// add claims to principal
if (user != null)
{
// add Oqtane claims
var principal = (ClaimsIdentity)claimsPrincipal.Identity;
UserSecurity.ResetClaimsIdentity(principal);
List<UserRole> userroles = _userRoles.GetUserRoles(user.UserId, user.SiteId).ToList();
var identity = UserSecurity.CreateClaimsIdentity(httpContext.GetAlias(), user, userroles);
var identity = UserSecurity.CreateClaimsIdentity(httpContext.GetAlias(), user, _userRoles.GetUserRoles(user.UserId, user.SiteId).ToList());
principal.AddClaims(identity.Claims);
// update user
@ -277,7 +289,7 @@ namespace Oqtane.Extensions
_users.UpdateUser(user);
_logger.Log(LogLevel.Information, "ExternalLogin", Enums.LogFunction.Security, "External User Login Successful For {Username} Using Provider {Provider}", user.Username, providerType);
}
else // user not logged in
else // user not valid
{
await httpContext.SignOutAsync();
}

View File

@ -0,0 +1,57 @@
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Oqtane.Extensions;
using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Shared;
namespace Oqtane.Infrastructure
{
internal class JwtMiddleware
{
private readonly RequestDelegate _next;
public JwtMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
if (context.Request.Headers.ContainsKey("Authorization"))
{
var alias = context.GetAlias();
if (alias != null)
{
var secret = context.GetSiteSettings().GetValue("JwtOptions:Secret", "");
if (!string.IsNullOrEmpty(secret))
{
var logger = context.RequestServices.GetService(typeof(ILogManager)) as ILogManager;
var jwtManager = context.RequestServices.GetService(typeof(IJwtManager)) as IJwtManager;
var token = context.Request.Headers["Authorization"].First().Split(" ").Last();
var user = jwtManager.ValidateToken(token, secret);
if (user != null)
{
// populate principal
var _userRoles = context.RequestServices.GetService(typeof(IUserRoleRepository)) as IUserRoleRepository;
var principal = (ClaimsIdentity)context.User.Identity;
UserSecurity.ResetClaimsIdentity(principal);
var identity = UserSecurity.CreateClaimsIdentity(alias, user, _userRoles.GetUserRoles(user.UserId, alias.SiteId).ToList());
principal.AddClaims(identity.Claims);
logger.Log(alias.SiteId, LogLevel.Information, "TokenValidation", Enums.LogFunction.Security, "Token Validated For User {Username}", user.Username);
}
else
{
logger.Log(alias.SiteId, LogLevel.Error, "TokenValidation", Enums.LogFunction.Security, "Token Validation Error");
}
}
}
}
await _next(context);
}
}
}

View File

@ -1,13 +0,0 @@
using Oqtane.Models;
using System.Security.Claims;
namespace Oqtane.Security
{
public interface IUserPermissions
{
bool IsAuthorized(ClaimsPrincipal user, string entityName, int entityId, string permissionName);
bool IsAuthorized(ClaimsPrincipal user, string permissionName, string permissions);
User GetUser(ClaimsPrincipal user);
User GetUser();
}
}

View File

@ -0,0 +1,66 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Oqtane.Models;
namespace Oqtane.Security
{
public interface IJwtManager
{
string GenerateToken(User user, string secret);
User ValidateToken(string token, string secret);
}
public class JwtManager : IJwtManager
{
public string GenerateToken(User user, string secret)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(secret);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[] { new Claim("id", user.UserId.ToString()), new Claim("name", user.Username) }),
Expires = DateTime.UtcNow.AddYears(1),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public User ValidateToken(string token, string secret)
{
if (!string.IsNullOrEmpty(token))
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(secret);
try
{
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.Zero
}, out SecurityToken validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var user = new User
{
UserId = int.Parse(jwtToken.Claims.FirstOrDefault(item => item.Type == "id")?.Value),
Username = jwtToken.Claims.FirstOrDefault(item => item.Type == "name")?.Value
};
return user;
}
catch
{
// error validating token
}
}
return null;
}
}
}

View File

@ -22,7 +22,7 @@ namespace Oqtane.Security
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
{
// permission is scoped based on entitynames and ids passed as querystring parameters or headers
// permission is scoped based on entitynames and ids passed as querystring parameters
var ctx = _httpContextAccessor.HttpContext;
if (ctx != null)
{

View File

@ -25,10 +25,11 @@ namespace Oqtane.Security
var alias = context.HttpContext.GetAlias();
if (alias != null)
{
// check if principal matches current site
if (context.Principal.Claims.FirstOrDefault(item => item.Type == ClaimTypes.GroupSid)?.Value != alias.SiteKey)
var claims = context.Principal.Claims;
// check if principal has roles and matches current site
if (!claims.Any(item => item.Type == ClaimTypes.Role) || claims.FirstOrDefault(item => item.Type == ClaimTypes.GroupSid)?.Value != alias.SiteKey)
{
// principal does not match site
var userRepository = context.HttpContext.RequestServices.GetService(typeof(IUserRepository)) as IUserRepository;
var userRoleRepository = context.HttpContext.RequestServices.GetService(typeof(IUserRoleRepository)) as IUserRoleRepository;
var _logger = context.HttpContext.RequestServices.GetService(typeof(ILogManager)) as ILogManager;
@ -39,28 +40,43 @@ namespace Oqtane.Security
{
// replace principal with roles for current site
List<UserRole> userroles = userRoleRepository.GetUserRoles(user.UserId, alias.SiteId).ToList();
if (userroles.Any())
{
var identity = UserSecurity.CreateClaimsIdentity(alias, user, userroles);
context.ReplacePrincipal(new ClaimsPrincipal(identity));
context.ShouldRenew = true;
if (!path.StartsWith("/api/")) // reduce log verbosity
Log(_logger, alias, "Permissions Updated For User {Username} Accessing {Url}", context.Principal.Identity.Name, path);
}
else
{
_logger.Log(alias.SiteId, LogLevel.Information, "LoginValidation", Enums.LogFunction.Security, "Permissions Updated For User {Username} Accessing Resource {Url}", context.Principal.Identity.Name, path);
// user has no roles - remove principal
context.RejectPrincipal();
Log(_logger, alias, "Permissions Removed For User {Username} Accessing {Url}", context.Principal.Identity.Name, path);
}
}
else
{
// user has no roles for site - remove principal
// user does not exist - remove principal
context.RejectPrincipal();
if (!path.StartsWith("/api/")) // reduce log verbosity
Log(_logger, alias, "Permissions Removed For User {Username} Accessing {Url}", context.Principal.Identity.Name, path);
}
}
}
else
{
_logger.Log(alias.SiteId, LogLevel.Information, "LoginValidation", Enums.LogFunction.Security, "Permissions Removed For User {Username} Accessing Resource {Url}", context.Principal.Identity.Name, path);
}
}
}
// user is signed in but tenant cannot be determined
}
}
}
return Task.CompletedTask;
}
private static void Log (ILogManager logger, Alias alias, string message, string username, string path)
{
if (!path.StartsWith("/api/")) // reduce log verbosity
{
logger.Log(alias.SiteId, LogLevel.Information, "LoginValidation", Enums.LogFunction.Security, message, username, path);
}
}
}
}

View File

@ -6,6 +6,14 @@ using Oqtane.Repository;
namespace Oqtane.Security
{
public interface IUserPermissions
{
bool IsAuthorized(ClaimsPrincipal user, string entityName, int entityId, string permissionName);
bool IsAuthorized(ClaimsPrincipal user, string permissionName, string permissions);
User GetUser(ClaimsPrincipal user);
User GetUser();
}
public class UserPermissions : IUserPermissions
{
private readonly IPermissionRepository _permissions;

View File

@ -167,6 +167,7 @@ namespace Oqtane
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseTenantResolution();
app.UseJwtAuthorization();
app.UseBlazorFrameworkFiles();
app.UseRouting();
app.UseAuthentication();

View File

@ -170,6 +170,11 @@ namespace Oqtane.Security
{
identity.RemoveClaim(claim);
}
var roles = identity.Claims.Where(item => item.Type == ClaimTypes.Role);
foreach (var role in roles)
{
identity.RemoveClaim(role);
}
}
}
}