This commit is contained in:
Paul Schneider 2026-02-17 21:50:17 +00:00
commit 181a44e396
7 changed files with 198 additions and 82 deletions

1
.vscode/launch.json vendored
View file

@ -25,6 +25,7 @@
"args": [], "args": [],
"cwd": "${workspaceFolder}/src/Org", "cwd": "${workspaceFolder}/src/Org",
"stopAtEntry": false, "stopAtEntry": false,
"justMyCode": false,
"serverReadyAction": { "serverReadyAction": {
"action": "openExternally", "action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)" "pattern": "\\bNow listening on:\\s+(https?://\\S+)"

View file

@ -20,10 +20,10 @@ namespace Yavsc
public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$"; public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$";
public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$"; public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$";
public const string LoginPath = "/signin"; public const string LoginPath = "~/signin";
public const string LogoutPath = "/signout"; public const string LogoutPath = "~/signout";
public const string AccessDeniedPath = "/Account/AccessDenied"; public const string AccessDeniedPath = "~/Account/AccessDenied";
public const string UserFilesPath = "/files"; public const string UserFilesPath = "/files";
public const string AvatarsPath = "/avatars"; public const string AvatarsPath = "/avatars";

View file

@ -13,7 +13,6 @@ using Yavsc.ViewModels.Account;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Abstract.Manage; using Yavsc.Abstract.Manage;
using Yavsc.Interface; using Yavsc.Interface;
using IdentityServer8.Test;
using IdentityServer8.Services; using IdentityServer8.Services;
using IdentityServer8.Stores; using IdentityServer8.Stores;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@ -22,14 +21,8 @@ using IdentityServer8.Models;
using Yavsc.Extensions; using Yavsc.Extensions;
using IdentityServer8.Events; using IdentityServer8.Events;
using IdentityServer8.Extensions; using IdentityServer8.Extensions;
using IdentityServer8;
using IdentityModel; using IdentityModel;
using System.Security.Cryptography;
using System.Text.Unicode;
using System.Text;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using System.Reflection;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
@ -104,15 +97,16 @@ namespace Yavsc.Controllers
return RedirectToAction("Challenge", "External", new { scheme = vm.ExternalLoginScheme, returnUrl }); return RedirectToAction("Challenge", "External", new { scheme = vm.ExternalLoginScheme, returnUrl });
} }
return View(vm); return View("Signin", vm);
} }
/// <summary> /// <summary>
/// Handle postback from username/password login /// Handle postback from username/password login
/// </summary> /// </summary>
[HttpPost] [HttpPost(Constants.LoginPath)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginInputModel model, string button) [AllowAnonymous]
public async Task<IActionResult> Login([FromForm] SignInModel model, [FromForm] string button)
{ {
// check if we are in the context of an authorization request // check if we are in the context of an authorization request
@ -148,7 +142,7 @@ namespace Yavsc.Controllers
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
var user = await _userManager.FindByNameAsync(model.Username); var user = await _userManager.FindByNameAsync(model.UserName);
if (user != null) if (user != null)
{ {
@ -162,7 +156,7 @@ namespace Yavsc.Controllers
// only set explicit expiration here if user chooses "remember me". // only set explicit expiration here if user chooses "remember me".
// otherwise we rely upon expiration configured in cookie middleware. // otherwise we rely upon expiration configured in cookie middleware.
await HttpContext.SignInAsync(user, _roleManager, model.RememberLogin, _dbContext); await HttpContext.SignInAsync(user, _roleManager, model.RememberMe, _dbContext);
if (context != null) if (context != null)
{ {
@ -194,13 +188,13 @@ namespace Yavsc.Controllers
} }
} }
await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId: context?.Client.ClientId)); await _events.RaiseAsync(new UserLoginFailureEvent(model.UserName, "invalid credentials", clientId: context?.Client.ClientId));
ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage); ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
} }
// something went wrong, show form with error // something went wrong, show form with error
var vm = await BuildLoginViewModelAsync(model); var vm = await BuildLoginViewModelAsync(model);
return View(vm); return View("Signin", vm);
} }
/// <summary> /// <summary>
@ -279,7 +273,7 @@ namespace Yavsc.Controllers
/*****************************************/ /*****************************************/
/* helper APIs for the AccountController */ /* helper APIs for the AccountController */
/*****************************************/ /*****************************************/
private async Task<LoginViewModel> BuildLoginViewModelAsync(string returnUrl) private async Task<SignInModel> BuildLoginViewModelAsync(string returnUrl)
{ {
var context = await _interaction.GetAuthorizationContextAsync(returnUrl); var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null) if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
@ -287,11 +281,12 @@ namespace Yavsc.Controllers
var local = context.IdP == IdentityServer8.IdentityServerConstants.LocalIdentityProvider; var local = context.IdP == IdentityServer8.IdentityServerConstants.LocalIdentityProvider;
// this is meant to short circuit the UI and only trigger the one external IdP // this is meant to short circuit the UI and only trigger the one external IdP
var vm = new LoginViewModel var vm = new SignInModel
{ {
EnableLocalLogin = local, EnableLocalLogin = local,
ReturnUrl = returnUrl, ReturnUrl = returnUrl,
Username = context?.LoginHint, UserName = context?.LoginHint,
IsExternalLoginOnly = false
}; };
if (!local) if (!local)
@ -327,21 +322,21 @@ namespace Yavsc.Controllers
} }
} }
return new LoginViewModel return new SignInModel
{ {
AllowRememberLogin = AccountOptions.AllowRememberLogin, RememberMe = AccountOptions.AllowRememberLogin,
EnableLocalLogin = allowLocal && AccountOptions.AllowLocalLogin, EnableLocalLogin = allowLocal && AccountOptions.AllowLocalLogin,
ReturnUrl = returnUrl, ReturnUrl = returnUrl,
Username = context?.LoginHint, UserName = context?.LoginHint,
ExternalProviders = providers.ToArray() ExternalProviders = providers.ToArray()
}; };
} }
private async Task<LoginViewModel> BuildLoginViewModelAsync(LoginInputModel model) private async Task<SignInModel> BuildLoginViewModelAsync(SignInModel model)
{ {
var vm = await BuildLoginViewModelAsync(model.ReturnUrl); var vm = await BuildLoginViewModelAsync(model.ReturnUrl);
vm.Username = model.Username; vm.UserName = model.UserName;
vm.RememberLogin = model.RememberLogin; vm.RememberMe = model.RememberMe;
return vm; return vm;
} }
@ -448,6 +443,8 @@ namespace Yavsc.Controllers
return View(new SignInModel return View(new SignInModel
{ {
ReturnUrl = returnUrl ?? "/", ReturnUrl = returnUrl ?? "/",
ExternalProviders = [],
EnableLocalLogin = true
}); });
/* /*
Note: When using an external login provider, redirect the query : Note: When using an external login provider, redirect the query :
@ -469,8 +466,6 @@ namespace Yavsc.Controllers
return View("AccessDenied", requestUrl); return View("AccessDenied", requestUrl);
} }
[AllowAnonymous]
[HttpPost(Constants.LoginPath)]
public async Task<IActionResult> SignIn(SignInModel model) public async Task<IActionResult> SignIn(SignInModel model)
{ {
if (Request.Method == "POST") // "hGbkk9B94NAae#aG" if (Request.Method == "POST") // "hGbkk9B94NAae#aG"
@ -480,33 +475,63 @@ namespace Yavsc.Controllers
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
// This doesn't count login failures towards account lockout var user = await _userManager.FindByNameAsync(model.UserName);
// To enable password failures to trigger account lockout, set lockoutOnFailure: true var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
var result = await _signInManager.PasswordSignInAsync(model.UserName, model.Password, model.RememberMe, lockoutOnFailure: false); if (user != null)
if (result.Succeeded)
{ {
// Redirect to returnUrl (ensure it's local to prevent open redirects)
return LocalRedirect(model.ReturnUrl);
var signin = await _signInManager.CheckPasswordSignInAsync(user, model.Password, true);
// validate username/password against in-memory store
if (signin.Succeeded)
{
await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
// only set explicit expiration here if user chooses "remember me".
// otherwise we rely upon expiration configured in cookie middleware.
await HttpContext.SignInAsync(user, _roleManager, model.RememberMe, _dbContext);
var authResult = await HttpContext.AuthenticateAsync();
if (!authResult.Succeeded)
{
return this.Unauthorized();
}
String bearer = await HttpContext.GetTokenAsync("Bearer", "Bearer");
HttpContext.Response.Cookies.Append("Bearer", bearer);
if (context != null)
{
if (context.IsNativeClient())
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage("Redirect", model.ReturnUrl);
}
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
return Redirect(model.ReturnUrl);
}
// request for a local page
if (Url.IsLocalUrl(model.ReturnUrl))
{
return Redirect(model.ReturnUrl);
}
else if (string.IsNullOrEmpty(model.ReturnUrl))
{
return Redirect("~/");
}
else
{
// user might have clicked on a malicious link - should be logged
throw new Exception("invalid return URL");
}
}
} }
if (result.RequiresTwoFactor) await _events.RaiseAsync(new UserLoginFailureEvent(model.UserName, "invalid credentials", clientId: context?.Client.ClientId));
{ ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
return RedirectToAction(nameof(SendCode), new { returnUrl = model.ReturnUrl, rememberMe = model.RememberMe });
}
if (result.IsLockedOut)
{
_logger.LogWarning(2, "User account locked out.");
return this.ViewOk("Lockout");
}
else
{
ModelState.AddModelError(string.Empty, $"Invalid login attempt. ({model.UserName}, {model.Password})");
return this.ViewOk(model);
}
} }
// If we got this far, something failed, redisplay form
ModelState.AddModelError(string.Empty, "Unexpected behavior: something failed ... you could try again, or contact me ...");
} }
else else
{ {
@ -1013,15 +1038,15 @@ namespace Yavsc.Controllers
return View("Error", new Exception("No mobile app service was activated")); return View("Error", new Exception("No mobile app service was activated"));
} }
else else
if (model.SelectedProvider == Constants.SMSFactor) if (model.SelectedProvider == Constants.SMSFactor)
{ {
return View("Error", new Exception("No SMS service was activated")); return View("Error", new Exception("No SMS service was activated"));
// await _smsSender.SendSmsAsync(_twilioSettings, await _userManager.GetPhoneNumberAsync(user), message); // await _smsSender.SendSmsAsync(_twilioSettings, await _userManager.GetPhoneNumberAsync(user), message);
} }
else // if (model.SelectedProvider == Constants.EMailFactor || model.SelectedProvider == "Default" ) else // if (model.SelectedProvider == Constants.EMailFactor || model.SelectedProvider == "Default" )
{ {
var sent = await this.SendEMailFactorAsync(user, model.SelectedProvider); var sent = await this.SendEMailFactorAsync(user, model.SelectedProvider);
} }
return View("VerifyCode", new VerifyCodeViewModel { Provider = model.SelectedProvider, ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe }); return View("VerifyCode", new VerifyCodeViewModel { Provider = model.SelectedProvider, ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe });
} }

View file

@ -123,7 +123,15 @@ public static class HostingExtensions
services.AddTransient<IExternalIdentityManager, ExternalIdentityManager>(); services.AddTransient<IExternalIdentityManager, ExternalIdentityManager>();
AddAuthentication(builder); services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
options.Authority = builder.Configuration.GetSection("Site")["Authority"];
options.TokenValidationParameters =
new() { ValidateAudience = false, RoleClaimType = Constants.RoleClaimType };
options.MapInboundClaims = true;
});
services.AddTransient<RoleManager<IdentityRole>>(); services.AddTransient<RoleManager<IdentityRole>>();
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>(); services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();
@ -199,6 +207,7 @@ public static class HostingExtensions
public static IServiceCollection LoadConfiguration(this WebApplicationBuilder builder) public static IServiceCollection LoadConfiguration(this WebApplicationBuilder builder)
{ {
var siteSection = builder.Configuration.GetSection("Site"); var siteSection = builder.Configuration.GetSection("Site");
var smtpSection = builder.Configuration.GetSection("Smtp"); var smtpSection = builder.Configuration.GetSection("Smtp");
var paypalSection = builder.Configuration.GetSection("Authentication:PayPal"); var paypalSection = builder.Configuration.GetSection("Authentication:PayPal");
// OAuth2AppSettings // OAuth2AppSettings
@ -276,19 +285,6 @@ public static class HostingExtensions
sql => sql.MigrationsAssembly(migrationsAssembly)); sql => sql.MigrationsAssembly(migrationsAssembly));
}); });
builder.Services.AddAuthentication(
CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = Constants.LoginPath; // Redirect here if unauthenticated
options.AccessDeniedPath = Constants.AccessDeniedPath;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
? CookieSecurePolicy.None
: CookieSecurePolicy.Always; // Use HTTPS in production
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; // Allows cross-site top-level navigation
options.ExpireTimeSpan = TimeSpan.FromMinutes(30); // Cookie expires in 30 mins
options.SlidingExpiration = true; // Renew cookie if user is active
});
builder.Services.Configure<IdentityOptions>(options => builder.Services.Configure<IdentityOptions>(options =>
{ {

View file

@ -0,0 +1,87 @@
@model SignInModel
<div class="login-page">
<div class="lead">
<h1>Login</h1>
<p>Choose how to login</p>
</div>
<partial name="_ValidationSummary" />
<div class="row">
@if (Model.EnableLocalLogin)
{
<div class="col-sm-6">
<div class="card">
<div class="card-header">
<h2>Local Account</h2>
</div>
<div class="card-body">
<form asp-action="Login" method="POST" asp-controller="Account">
<input type="hidden" asp-for="ReturnUrl" />
<div class="form-group">
<label asp-for="UserName"></label>
<input class="form-control" placeholder="Username" asp-for="UserName" autofocus>
</div>
<div class="form-group">
<label asp-for="Password"></label>
<input type="password" class="form-control" placeholder="Password" asp-for="Password" autocomplete="off">
</div>
@if (Model.AllowRememberLogin)
{
<div class="form-group">
<div class="form-check">
<input class="form-check-input" asp-for="RememberMe">
<label class="form-check-label" asp-for="RememberMe">
Remember My Login
</label>
</div>
</div>
}
<button class="btn btn-primary" name="button" value="login">Login</button>
<button class="btn btn-secondary" name="button" value="cancel">Cancel</button>
</form>
</div>
</div>
</div>
}
@if (Model.ExternalProviders.Any())
{
<div class="col-sm-6">
<div class="card">
<div class="card-header">
<h2>External Account</h2>
</div>
<div class="card-body">
<ul class="list-inline">
@foreach (var provider in Model.ExternalProviders)
{
<li class="list-inline-item">
<a class="btn btn-secondary"
asp-controller="External"
asp-action="Challenge"
asp-route-scheme="@provider.AuthenticationScheme"
asp-route-returnUrl="@Model.ReturnUrl">
@provider.DisplayName
</a>
</li>
}
</ul>
</div>
</div>
</div>
}
@if (!Model.EnableLocalLogin && !Model.ExternalProviders.Any())
{
<div class="alert alert-warning">
<strong>Invalid login request</strong>
There are no login schemes configured for this request.
</div>
}
</div>
</div>

View file

@ -1,15 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Runtime.InteropServices;
using Yavsc.Attributes.Validation; using Yavsc.Attributes.Validation;
namespace Yavsc.ViewModels.Account namespace Yavsc.ViewModels.Account
{ {
// TODO external autentication providers // TODO external autentication providers
public class SignInModel public class SignInModel
{ {
/// <summary> /// <summary>
/// Local user's name. /// Local user's name.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
@ -20,7 +21,7 @@ namespace Yavsc.ViewModels.Account
/// Local user's password . /// Local user's password .
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[YaRequired] [Required]
[DataType(DataType.Password)] [DataType(DataType.Password)]
public string Password { get; set; } public string Password { get; set; }
@ -37,7 +38,7 @@ namespace Yavsc.ViewModels.Account
/// and user password credentials. /// and user password credentials.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public string Provider { get; set; } public string? Provider { get; set; }
/// <summary> /// <summary>
@ -45,14 +46,20 @@ namespace Yavsc.ViewModels.Account
/// but the one called once authorized. /// but the one called once authorized.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public string ReturnUrl { get; set; } public string? ReturnUrl { get; set; }
public bool EnableLocalLogin { get; set; }
public ExternalProvider[]? ExternalProviders { get; set; }
public bool IsExternalLoginOnly { get; set; }
public String? ExternalLoginScheme { get; set; }
public bool AllowRememberLogin { get; set; }
} }
public class YaAuthenticationDescription { public class YaAuthenticationDescription
{
public string DisplayName { get; set; } public string DisplayName { get; set; }
public string AuthenticationScheme { get; set; } public string AuthenticationScheme { get; set; }
public IDictionary<string,object> Items { get; set; } public IDictionary<string, object> Items { get; set; }
} }
} }