using Duende.IdentityServer; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Localization; using System.Security.Claims; using System.Text.RegularExpressions; using ZeroFramework.IdentityServer.API.IdentityStores; using ZeroFramework.IdentityServer.API.Models.Accounts; using ZeroFramework.IdentityServer.API.Services; using ZeroFramework.IdentityServer.API.Tenants; namespace ZeroFramework.IdentityServer.API.Controllers { [Authorize] public class AccountController(UserManager userManager, SignInManager signInManager, ISmsSender smsSender, ILoggerFactory loggerFactory, IIdentityServerInteractionService interactionService, IDistributedCache distributedCache, IStringLocalizerFactory localizerFactory, ITenantProvider tenantProvider, ICurrentTenant currentTenant, IAuthenticationHandlerProvider authenticationHandlerProvider) : Controller { private readonly UserManager _userManager = userManager; private readonly SignInManager _signInManager = signInManager; private readonly ISmsSender _smsSender = smsSender; private readonly ILogger _logger = loggerFactory.CreateLogger(); private readonly IDistributedCache _distributedCache = distributedCache; private readonly IIdentityServerInteractionService _interactionService = interactionService; private readonly IStringLocalizerFactory _localizerFactory = localizerFactory; private readonly ITenantProvider _tenantProvider = tenantProvider; private readonly ICurrentTenant _currentTenant = currentTenant; private readonly IAuthenticationHandlerProvider _authenticationHandlerProvider = authenticationHandlerProvider; [HttpGet] [AllowAnonymous] public async Task Login(string? returnUrl = null) { var authorizationRequest = await _interactionService.GetAuthorizationContextAsync(returnUrl); _logger.LogInformation("The current tenant id or name {Tenant}", authorizationRequest?.Tenant); LoginViewModel? loginViewModel = null; if (authorizationRequest?.Client?.ClientId is not null) { loginViewModel = new LoginViewModel { UserName = authorizationRequest.LoginHint }; } ViewData["ReturnUrl"] = returnUrl; return View(loginViewModel); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task Login(LoginViewModel model, string? returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; if (ModelState.IsValid) { // This doesn't count login failures towards account lockout // To enable password failures to trigger account lockout, set lockoutOnFailure: true IdentityTenant? identityTenant = await _tenantProvider.GetTenantAsync(); string currentUserName = model.UserName; string pattern = @"^(?[a-zA-Z0-9]+)@(?[a-zA-Z0-9]+)$"; Match match = Regex.Match(model.UserName, pattern, RegexOptions.IgnoreCase); if (match.Success) { string tenantName = match.Groups["tenantName"].Value; identityTenant = await _tenantProvider.FindTenantAsync(tenantName); } if (!match.Success && identityTenant is not null) { currentUserName += $"@{identityTenant.Name}"; } using (_currentTenant.Change(identityTenant?.Id, identityTenant?.Name)) { ApplicationUser? user = _userManager.Users.FirstOrDefault(u => u.UserName == currentUserName || u.PhoneNumber == currentUserName); var _localizer = _localizerFactory.Create(typeof(LoginViewModel)); if (user is null) { ModelState.AddModelError(nameof(user.UserName), _localizer["Invalid userid or password."].Value); return View(model); } var loginResult = await _signInManager.PasswordSignInAsync(user.UserName!, model.Password, model.RememberMe, lockoutOnFailure: false); if (loginResult.Succeeded) { // make sure the returnUrl is still valid, and if yes - redirect back to authorize endpoint if (_interactionService.IsValidReturnUrl(returnUrl)) { return Redirect(returnUrl ?? string.Empty); } return RedirectToLocal(returnUrl); } if (loginResult.RequiresTwoFactor) { return RedirectToAction(nameof(SendCode), new { ReturnUrl = returnUrl, model.RememberMe }); } if (loginResult.IsLockedOut) { return View("Lockout"); } ModelState.AddModelError(string.Empty, _localizer["Invalid userid or password."].Value); return View(model); } } // If we got this far, something failed, redisplay form return View(model); } [HttpGet] [AllowAnonymous] public IActionResult Register(string? returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; return View(); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task Register(RegisterViewModel model, string? returnUrl = null) { ViewData["ReturnUrl"] = returnUrl; if (ModelState.IsValid) { if (string.IsNullOrWhiteSpace(model.ConfirmedCode) || await _distributedCache.GetStringAsync(model.PhoneNumber) != model.ConfirmedCode) { var _localizer = _localizerFactory.Create(typeof(RegisterViewModel)); ModelState.AddModelError(nameof(model.ConfirmedCode), _localizer["Invalid confirmed code."].Value); return View(model); } IdentityTenant? identityTenant = await _tenantProvider.GetTenantAsync(); var user = new ApplicationUser { UserName = model.UserName, PhoneNumber = model.PhoneNumber, TenantId = identityTenant?.Id }; var registerResult = await _userManager.CreateAsync(user, model.Password); var token = await _userManager.GenerateChangePhoneNumberTokenAsync(user, user.PhoneNumber); await _userManager.ChangePhoneNumberAsync(user, user.PhoneNumber, token); // Get the information about the user from the external login provider var info = await _signInManager.GetExternalLoginInfoAsync(); if (registerResult.Succeeded && info is not null) { registerResult = await _userManager.AddLoginAsync(user, info); if (registerResult.Succeeded) { await _signInManager.SignInAsync(user, isPersistent: false); _logger.LogInformation(6, "User created an account using {Name} provider.", info.LoginProvider); // Update any authentication tokens as well await _signInManager.UpdateExternalAuthenticationTokensAsync(info); return RedirectToLocal(returnUrl); } } registerResult.Errors.ToList().ForEach(e => ModelState.AddModelError(string.Empty, e.Description)); } // If we got this far, something failed, redisplay form return View(model); } [HttpGet] public async Task Logout(string logoutId) { if (User.Identity?.IsAuthenticated == false) { // if the user is not authenticated, then just show logged out page return await Logout(new LogoutViewModel { LogoutId = logoutId }); } //Test for Xamarin. LogoutRequest logoutRequest = await _interactionService.GetLogoutContextAsync(logoutId); if (logoutRequest?.ShowSignoutPrompt == false) { //it's safe to automatically sign-out return await Logout(new LogoutViewModel { LogoutId = logoutId }); } // show the logout prompt. this prevents attacks where the user // is automatically signed out by another malicious web page. LogoutViewModel logoutViewModel = new() { LogoutId = logoutId }; return View(logoutViewModel); } [HttpPost] [ValidateAntiForgeryToken] public async Task Logout(LogoutViewModel model) { string? idp = User?.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; if (idp is not null && idp != IdentityServerConstants.LocalIdentityProvider) { // if there's no current logout context, we need to create one // this captures necessary info from the current logged in user // before we signout and redirect away to the external IdP for signout model.LogoutId ??= await _interactionService.CreateLogoutContextAsync(); string? url = Url.Action("Logout", new { model.LogoutId }); if (await _authenticationHandlerProvider.GetHandlerAsync(HttpContext, idp) is IAuthenticationSignOutHandler) { // hack: try/catch to handle social providers that throw await HttpContext.SignOutAsync(idp, new AuthenticationProperties { RedirectUri = url }); } } // delete authentication cookie await HttpContext.SignOutAsync(); await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme); // set this so UI rendering sees an anonymous user HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); // get context information (client name, post logout redirect URI and iframe for federated signout) LogoutRequest logoutRequest = await _interactionService.GetLogoutContextAsync(model.LogoutId); return logoutRequest?.PostLogoutRedirectUri is null ? RedirectToLocal(null) : Redirect(logoutRequest?.PostLogoutRedirectUri ?? string.Empty); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public IActionResult ExternalLogin(string provider, string? returnUrl = "~/") { // validate returnUrl - either it is a valid OIDC URL or back to a local page if (Url.IsLocalUrl(returnUrl) == false && !_interactionService.IsValidReturnUrl(returnUrl)) { // user might have clicked on a malicious link - should be logged throw new Exception("invalid return url"); } // Request a redirect to the external login provider. var redirectUrl = Url.Action(nameof(ExternalLoginCallback), new { ReturnUrl = returnUrl }); var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); return Challenge(properties, provider); } [HttpGet] [AllowAnonymous] public async Task ExternalLoginCallback(string? returnUrl = null, string? remoteError = null) { ViewData["ReturnUrl"] = returnUrl; if (remoteError != null) { ModelState.AddModelError(string.Empty, $"Error from external provider: {remoteError}"); return View(nameof(Login)); } var info = await _signInManager.GetExternalLoginInfoAsync(); if (info is null) { return RedirectToAction(nameof(Login)); } ApplicationUser? user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); if (user is not null) { await _signInManager.SignInAsync(user, true); } // Sign in the user with this external login provider if the user already has a login. var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); if (result.Succeeded && returnUrl is not null && user is not null) { // Update any authentication tokens if login succeeded await _signInManager.UpdateExternalAuthenticationTokensAsync(info); _logger.LogInformation(5, "User logged in with {Name} provider.", info.LoginProvider); return RedirectToLocal(returnUrl); } return View(viewName: nameof(Register)); } [HttpGet] [AllowAnonymous] public IActionResult ForgotPassword() { return View(); } [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task ForgotPassword(ForgotPasswordViewModel model) { if (ModelState.IsValid) { ApplicationUser? user = _userManager.Users.FirstOrDefault(u => u.PhoneNumber == model.PhoneNumber); if (user is null) { ModelState.AddModelError(string.Empty, "Phone number does not exist."); return View(model); } if (string.IsNullOrWhiteSpace(model.ConfirmedCode) || await _distributedCache.GetStringAsync(model.PhoneNumber) != model.ConfirmedCode) { ModelState.AddModelError(nameof(model.ConfirmedCode), "Invalid confirmed code."); return View(model); } var code = await _userManager.GeneratePasswordResetTokenAsync(user); var identityResult = await _userManager.ResetPasswordAsync(user, code, model.Password); if (identityResult.Succeeded) { return RedirectToLocal(null); } identityResult.Errors.ToList().ForEach(e => ModelState.AddModelError(string.Empty, e.Description)); } // If we got this far, something failed, redisplay form return View(model); } [HttpPost] [AllowAnonymous] public async Task SendCode([System.ComponentModel.DataAnnotations.Phone] string phoneNumber) { if (!ModelState.IsValid) { return BadRequest(); } // Generate the token and send it string? code = await _distributedCache.GetStringAsync(phoneNumber); if (string.IsNullOrWhiteSpace(code)) { Random random = new((int)DateTime.Now.Ticks); code = random.Next(100000, 999999).ToString(); } await _distributedCache.SetStringAsync(phoneNumber, code, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }); await _smsSender.SendSmsAsync(phoneNumber, code); return Ok(); } #region Helpers private IActionResult RedirectToLocal(string? returnUrl) { return Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToAction(nameof(HomeController.Index), "Home"); } #endregion [AcceptVerbs("GET", "POST"), AllowAnonymous] public IActionResult VerifyPhoneNumber(string phoneNumber) { var _localizer = _localizerFactory.Create(typeof(RegisterViewModel)); if (_userManager.Users.Any(u => u.PhoneNumber == phoneNumber)) { return Json(_localizer["Phone number is already in use."].Value); } return Json(true); } [AcceptVerbs("GET", "POST"), AllowAnonymous] public IActionResult VerifyUserName(string userName) { var _localizer = _localizerFactory.Create(typeof(RegisterViewModel)); if (_userManager.Users.Any(u => u.UserName == userName)) { return Json(_localizer["User name is already in use."].Value); } return Json(true); } } }