diff --git a/README.md b/README.md index 7e43609..b45ad8b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ The authentication server consists of two parts: The description of the API endpoints can be found in the swagger documentation. +See [external login state/S256 binding and rollout](docs/external-login.md) for +the companion AdminUI authentication changes and required server-first upgrade. + ## Concurrent browser sessions By default, the same user can sign in from multiple browsers or devices using the diff --git a/docs/external-login.md b/docs/external-login.md new file mode 100644 index 0000000..9568cec --- /dev/null +++ b/docs/external-login.md @@ -0,0 +1,44 @@ +# External login: state and S256 binding + +Deploy this server before deploying the companion AdminUI changes in +`SenseNet/sn-client` branch `codex/auth-lifecycle-fixes`. Existing clients remain +compatible when they omit all binding fields. The new client requires a bound +callback and reports an authentication error against an older server. + +The browser creates a fresh cryptographic `state` and RFC 7636 verifier for each +login, storing them in sessionStorage for that tab, repository, auth server and +callback. The `/Login` request adds `state`, `codeChallenge` (base64url SHA-256 of +the verifier) and `codeChallengeMethod=S256` to `RedirectUrl` and `CallbackUri`. +Partial or malformed binding is rejected. The callback must share the allowed +RedirectUrl's scheme, host and port. + +Password, remembered login and MFA keep the original binding. Registration and +password recovery preserve the encoded login context. The successful callback +contains `auth_code` and `state`. The client validates and consumes its matching +pending transaction before posting `{ token, codeVerifier }` to +`/api/auth/convert-auth-token`. + +The server verifies proof before atomically consuming a code. Missing/wrong proof +cannot redeem or burn a bound code. A proof-bearing request cannot redeem a +legacy unbound code. Bound MFA tokens cannot bypass proof through the direct +MFA login API. Session responses retain their existing accessToken/refreshToken +fields, and session rotation, parallel sessions and SingleSessionPerUser keep +their existing scope. + +`AuthTokenExpiryMinutes` and `MultiFactorAuthExpiryMinutes` now expire in minutes. +Previously those two settings were inadvertently interpreted as days. Review +their configured values before rollout. Temporary codes and sessions still live +in one server process's memory. + +Validation: + +```powershell +dotnet test src/SenseNetAuth.sln --no-restore +dotnet publish src/sn-auth/SenseNetAuth.csproj -c Release --no-restore +``` + +Regression coverage includes real HTTP forms/callbacks/navigation, proof and +single-use races, MFA, legacy compatibility, minute expiry, and the existing +single/parallel-session cases. Complete the companion AdminUI's +`apps/sensenet/AUTHENTICATION-TESTING.md` matrix against the intended test servers +before promoting both changes. diff --git a/src/sn-auth/Models/AuthorizationCodeBinding.cs b/src/sn-auth/Models/AuthorizationCodeBinding.cs new file mode 100644 index 0000000..c915ca7 --- /dev/null +++ b/src/sn-auth/Models/AuthorizationCodeBinding.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.WebUtilities; +using SenseNetAuth.Infrastructure.Exceptions; +using SenseNetAuth.Models.Constants; + +namespace SenseNetAuth.Models; + +// Stored with the temporary code (including the MFA step), never with a session. +public sealed record AuthorizationCodeBinding(string State, string CodeChallenge, string CallbackUri) +{ + public static AuthorizationCodeBinding? Create(string? state, string? challenge, string? method, string? callbackUri) + { + if (string.IsNullOrEmpty(state) && string.IsNullOrEmpty(challenge) && string.IsNullOrEmpty(method)) + return null; // Existing clients may still use the unbound protocol during rollout. + + if (string.IsNullOrEmpty(state) || state.Length > 512 || + !Regex.IsMatch(state, @"\A[A-Za-z0-9_-]{32,512}\z") || method != "S256" || + challenge == null || !Regex.IsMatch(challenge, @"\A[A-Za-z0-9_-]{43}\z")) + throw new BadRequestException(ResponseMessages.InvalidAuthToken); + + return new AuthorizationCodeBinding(state, challenge, callbackUri ?? string.Empty); + } + + public bool Matches(string? verifier) + { + if (verifier == null || !Regex.IsMatch(verifier, @"\A[A-Za-z0-9._~-]{43,128}\z")) + return false; + + var challenge = WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(CodeChallenge), Encoding.ASCII.GetBytes(challenge)); + } +} diff --git a/src/sn-auth/Models/LoginRequest.cs b/src/sn-auth/Models/LoginRequest.cs index 0840a35..f6b345a 100644 --- a/src/sn-auth/Models/LoginRequest.cs +++ b/src/sn-auth/Models/LoginRequest.cs @@ -7,4 +7,8 @@ public class LoginRequest public string RememberMeToken { get; set; } = string.Empty; public bool RememberMeRequested { get; set; } public required string SiteUrl { get; set; } + public string State { get; set; } = string.Empty; + public string CodeChallenge { get; set; } = string.Empty; + public string CodeChallengeMethod { get; set; } = string.Empty; + public string CallbackUri { get; set; } = string.Empty; } diff --git a/src/sn-auth/Models/LoginResponse.cs b/src/sn-auth/Models/LoginResponse.cs index 9902598..c9bd858 100644 --- a/src/sn-auth/Models/LoginResponse.cs +++ b/src/sn-auth/Models/LoginResponse.cs @@ -5,6 +5,8 @@ public class LoginResponse public string AccessToken { get; set; } = string.Empty; public string RefreshToken { get; set; } = string.Empty; public string AuthToken { get; set; } = string.Empty; + public string? State { get; set; } + public string? CallbackUri { get; set; } public bool MultiFactorRequired { get; set; } public string QrCodeSetupImageUrl { get; set; } = string.Empty; public string ManualEntryKey { get; set; } = string.Empty; diff --git a/src/sn-auth/Models/TokenRequest.cs b/src/sn-auth/Models/TokenRequest.cs index 072e619..58d7d0a 100644 --- a/src/sn-auth/Models/TokenRequest.cs +++ b/src/sn-auth/Models/TokenRequest.cs @@ -3,4 +3,5 @@ public class TokenRequest { public string Token { get; set; } = string.Empty; + public string? CodeVerifier { get; set; } } diff --git a/src/sn-auth/Models/ViewModels/ExternalLoginFlow.cs b/src/sn-auth/Models/ViewModels/ExternalLoginFlow.cs new file mode 100644 index 0000000..563952b --- /dev/null +++ b/src/sn-auth/Models/ViewModels/ExternalLoginFlow.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.WebUtilities; +using SenseNetAuth.Models; + +namespace SenseNetAuth.Models.ViewModels; + +public class ExternalLoginFlow +{ + public string RedirectUrl { get; set; } = string.Empty; + public string CallbackUri { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string CodeChallenge { get; set; } = string.Empty; + public string CodeChallengeMethod { get; set; } = string.Empty; + + public string QueryString => Microsoft.AspNetCore.Http.QueryString.Create(new Dictionary + { + ["redirectUrl"] = RedirectUrl, ["callbackUri"] = CallbackUri, + ["state"] = State, ["codeChallenge"] = CodeChallenge, ["codeChallengeMethod"] = CodeChallengeMethod + }).ToString(); + + public static ExternalLoginFlow FromForm(IFormCollection form) => new() + { + RedirectUrl = form["RedirectUrl"].ToString(), CallbackUri = form["CallbackUri"].ToString(), + State = form["State"].ToString(), CodeChallenge = form["CodeChallenge"].ToString(), + CodeChallengeMethod = form["CodeChallengeMethod"].ToString() + }; + + public T CopyTo(T model) where T : ExternalLoginFlow + { + model.RedirectUrl = RedirectUrl; + model.CallbackUri = CallbackUri; + model.State = State; + model.CodeChallenge = CodeChallenge; + model.CodeChallengeMethod = CodeChallengeMethod; + return model; + } + + public bool IsValid(IEnumerable allowedHosts) + { + if (!allowedHosts.Contains(RedirectUrl) || !TryCallback(CallbackUri, out _)) + return false; + try { _ = AuthorizationCodeBinding.Create(State, CodeChallenge, CodeChallengeMethod, CallbackUri); } + catch { return false; } + return true; + } + + private bool TryCallback(string callbackUri, out Uri? callback) + { + callback = null; + return Uri.TryCreate(RedirectUrl, UriKind.Absolute, out var site) && + (site.Scheme == "https" || site.Scheme == "http") && + Uri.TryCreate(site, callbackUri, out callback) && + callback.Scheme == site.Scheme && callback.Host == site.Host && callback.Port == site.Port && + string.IsNullOrEmpty(callback.UserInfo); + } + + public string CallbackUrl(LoginResponse response) + { + // Bound MFA responses carry the callback that was validated before password authentication. + if (!TryCallback(response.CallbackUri ?? CallbackUri, out var callback)) + throw new InvalidOperationException("Invalid login callback"); + var query = new Dictionary { ["auth_code"] = response.AuthToken }; + if (!string.IsNullOrEmpty(response.State)) query["state"] = response.State; + return QueryHelpers.AddQueryString(callback!.AbsoluteUri, query); + } +} diff --git a/src/sn-auth/Models/ViewModels/ForgottenPasswordViewModel.cs b/src/sn-auth/Models/ViewModels/ForgottenPasswordViewModel.cs index 37ba843..dfa98e1 100644 --- a/src/sn-auth/Models/ViewModels/ForgottenPasswordViewModel.cs +++ b/src/sn-auth/Models/ViewModels/ForgottenPasswordViewModel.cs @@ -1,10 +1,8 @@ namespace SenseNetAuth.Models.ViewModels; -public class ForgottenPasswordViewModel +public class ForgottenPasswordViewModel : ExternalLoginFlow { public string RepositoryUrl { get; set; } = string.Empty; - public string RedirectUrl { get; set; } = string.Empty; - public string CallbackUri { get; set; } = string.Empty; public string ErrorMessage { get; set; } = string.Empty; public string SuccessMessage { get; set; } = string.Empty; } diff --git a/src/sn-auth/Models/ViewModels/LoginViewModel.cs b/src/sn-auth/Models/ViewModels/LoginViewModel.cs index f5bf216..9d64a5f 100644 --- a/src/sn-auth/Models/ViewModels/LoginViewModel.cs +++ b/src/sn-auth/Models/ViewModels/LoginViewModel.cs @@ -1,10 +1,8 @@ namespace SenseNetAuth.Models.ViewModels; -public class LoginViewModel +public class LoginViewModel : ExternalLoginFlow { public string RepositoryUrl { get; set; } = string.Empty; - public string RedirectUrl { get; set; } = string.Empty; - public string CallbackUri { get; set; } = string.Empty; public bool IsRegistrationEnabled { get; set; } public bool IsHostInvalid { get; set; } public string ErrorMessage { get; set; } = string.Empty; diff --git a/src/sn-auth/Models/ViewModels/MultiFactorViewModel.cs b/src/sn-auth/Models/ViewModels/MultiFactorViewModel.cs index f67f9d2..b01a0c0 100644 --- a/src/sn-auth/Models/ViewModels/MultiFactorViewModel.cs +++ b/src/sn-auth/Models/ViewModels/MultiFactorViewModel.cs @@ -1,10 +1,8 @@ namespace SenseNetAuth.Models.ViewModels; -public class MultiFactorViewModel +public class MultiFactorViewModel : ExternalLoginFlow { public string RepositoryUrl { get; set; } = string.Empty; - public string RedirectUrl { get; set; } = string.Empty; - public string CallbackUri { get; set; } = string.Empty; public bool MultiFactorRequired { get; set; } public string QrCodeSetupImageUrl { get; set; } = string.Empty; public string ManualEntryKey { get; set; } = string.Empty; diff --git a/src/sn-auth/Models/ViewModels/PasswordRecoveryViewModel.cs b/src/sn-auth/Models/ViewModels/PasswordRecoveryViewModel.cs index d5e3663..1da16ad 100644 --- a/src/sn-auth/Models/ViewModels/PasswordRecoveryViewModel.cs +++ b/src/sn-auth/Models/ViewModels/PasswordRecoveryViewModel.cs @@ -1,10 +1,8 @@ namespace SenseNetAuth.Models.ViewModels; -public class PasswordRecoveryViewModel +public class PasswordRecoveryViewModel : ExternalLoginFlow { public string RepositoryUrl { get; set; } = string.Empty; - public string RedirectUrl { get; set; } = string.Empty; - public string CallbackUri { get; set; } = string.Empty; public string ErrorMessage { get; set; } = string.Empty; public string RecoveryToken { get; set; } = string.Empty; } diff --git a/src/sn-auth/Models/ViewModels/RegistrationViewModel.cs b/src/sn-auth/Models/ViewModels/RegistrationViewModel.cs index 6c642b4..b145f24 100644 --- a/src/sn-auth/Models/ViewModels/RegistrationViewModel.cs +++ b/src/sn-auth/Models/ViewModels/RegistrationViewModel.cs @@ -1,10 +1,8 @@ namespace SenseNetAuth.Models.ViewModels; -public class RegistrationViewModel +public class RegistrationViewModel : ExternalLoginFlow { public string RepositoryUrl { get; set; } = string.Empty; - public string RedirectUrl { get; set; } = string.Empty; - public string CallbackUri { get; set; } = string.Empty; public bool IsRegistrationEnabled { get; set; } public bool IsHostInvalid { get; set; } public string ErrorMessage { get; set; } = string.Empty; diff --git a/src/sn-auth/Services/AuthService.cs b/src/sn-auth/Services/AuthService.cs index 96ae1f5..05571d0 100644 --- a/src/sn-auth/Services/AuthService.cs +++ b/src/sn-auth/Services/AuthService.cs @@ -106,6 +106,9 @@ public async Task MultiFactorLoginAsync(MultiFactorLoginRequest l if (!userInfo.HasValue || userInfo.Value.SiteUrl != loginRequest.SiteUrl) throw new BadRequestException(ResponseMessages.InvalidMultiFactorToken); + if (directLogin && userInfo.Value.AuthorizationCodeBinding != null) + throw new BadRequestException(ResponseMessages.InvalidMultiFactorToken); + if (!await _userService.ValidateTwoFactorCodeAsync(userInfo.Value.UserId, loginRequest.MultiFactorCode, cancel)) throw new BadRequestException(ResponseMessages.InvalidMultiFactorCode); @@ -119,7 +122,9 @@ public async Task MultiFactorLoginAsync(MultiFactorLoginRequest l return new LoginResponse { - AuthToken = _authTokenProvider.CreateToken(consumed.UserId, consumed.SiteUrl) + AuthToken = _authTokenProvider.CreateToken(consumed), + State = consumed.AuthorizationCodeBinding?.State, + CallbackUri = consumed.AuthorizationCodeBinding?.CallbackUri }; } } @@ -157,6 +162,7 @@ private LoginResponse CreateTokenPair(UserInfo userInfo) { lock (_tokenLock) { + userInfo = userInfo with { AuthorizationCodeBinding = null }; if (string.IsNullOrEmpty(userInfo.SessionId)) userInfo = userInfo with { SessionId = Guid.NewGuid().ToString("N") }; @@ -242,6 +248,8 @@ public async Task PasswordRecoveryAsync(PasswordRecoveryRequest passwordRecovery public async Task AuthenticateAsync(LoginRequest loginRequest, CancellationToken cancel) { + var binding = AuthorizationCodeBinding.Create(loginRequest.State, loginRequest.CodeChallenge, + loginRequest.CodeChallengeMethod, loginRequest.CallbackUri); int userId; if (!string.IsNullOrEmpty(loginRequest.RememberMeToken)) { @@ -260,7 +268,8 @@ public async Task AuthenticateAsync(LoginRequest loginRequest, Ca var multiFactorInfo = await _userService.GetMultiFactorAuthenticationInfoAsync(userId, cancel); - var response = new LoginResponse(); + var response = new LoginResponse { State = binding?.State, CallbackUri = binding?.CallbackUri }; + var tokenUser = new UserInfo(userId, default, loginRequest.SiteUrl) { AuthorizationCodeBinding = binding }; if (loginRequest.RememberMeRequested && string.IsNullOrEmpty(loginRequest.RememberMeToken)) { @@ -277,7 +286,7 @@ public async Task AuthenticateAsync(LoginRequest loginRequest, Ca { response.MultiFactorRequired = true; lock (_tokenLock) - response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(userId, loginRequest.SiteUrl); + response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(tokenUser); if (!multiFactorInfo.MultiFactorRegistered) { @@ -288,7 +297,7 @@ public async Task AuthenticateAsync(LoginRequest loginRequest, Ca else { lock (_tokenLock) - response.AuthToken = _authTokenProvider.CreateToken(userId, loginRequest.SiteUrl); + response.AuthToken = _authTokenProvider.CreateToken(tokenUser); } return response; @@ -298,6 +307,14 @@ public LoginResponse ConvertAuthToken(TokenRequest tokenRequest) { lock (_tokenLock) { + var pending = _authTokenProvider.GetValidUserInfo(tokenRequest.Token) + ?? throw new UnauthorizedException(ResponseMessages.InvalidAuthToken); + // Check proof before consuming: an invalid request cannot burn a legitimate code. + // A proof-bearing client must also reject legacy, unbound codes (downgrade protection). + if (pending.AuthorizationCodeBinding is { } binding + ? !binding.Matches(tokenRequest.CodeVerifier) + : tokenRequest.CodeVerifier != null) + throw new UnauthorizedException(ResponseMessages.InvalidAuthToken); var userInfo = _authTokenProvider.ConsumeToken(tokenRequest.Token) ?? throw new UnauthorizedException(ResponseMessages.InvalidAuthToken); return CreateTokenPair(userInfo); diff --git a/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs index e517436..945038b 100644 --- a/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs @@ -13,11 +13,14 @@ public AuthTokenProvider(IOptions options) } public override string CreateToken(int userId, string siteUrl) + => CreateToken(new UserInfo(userId, default, siteUrl)); + + public override string CreateToken(UserInfo userInfo) { - var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.AuthTokenExpiryMinutes)); + var expiration = DateTimeOffset.UtcNow.AddMinutes(_jwtSettings.AuthTokenExpiryMinutes); var token = GenerateUniqueToken(128); - StoreToken(token, new UserInfo(userId, expiration, siteUrl), _jwtSettings.SingleSessionPerUser); + StoreToken(token, userInfo with { Expiry = expiration }, _jwtSettings.SingleSessionPerUser); return token; } diff --git a/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs index c220267..c890d95 100644 --- a/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Options; -using SenseNet.Client; using SenseNetAuth.Models.Options; -using System.Security.Cryptography; namespace SenseNetAuth.TokenProviders.InMemory; @@ -15,11 +13,14 @@ public MultiFactorAuthTokenProvider(IOptions jwtSettings) } public override string CreateToken(int userId, string siteUrl) + => CreateToken(new UserInfo(userId, default, siteUrl)); + + public override string CreateToken(UserInfo userInfo) { - var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.MultiFactorAuthExpiryMinutes)); + var expiration = DateTimeOffset.UtcNow.AddMinutes(_jwtSettings.MultiFactorAuthExpiryMinutes); var token = GenerateUniqueToken(128); - StoreToken(token, new UserInfo(userId, expiration, siteUrl), _jwtSettings.SingleSessionPerUser); + StoreToken(token, userInfo with { Expiry = expiration }, _jwtSettings.SingleSessionPerUser); return token; } diff --git a/src/sn-auth/TokenProviders/UserInfo.cs b/src/sn-auth/TokenProviders/UserInfo.cs index 18374d3..ff49bd6 100644 --- a/src/sn-auth/TokenProviders/UserInfo.cs +++ b/src/sn-auth/TokenProviders/UserInfo.cs @@ -3,6 +3,7 @@ public record struct UserInfo(int UserId, DateTimeOffset Expiry, string SiteUrl) { public string SessionId { get; init; } = string.Empty; + public Models.AuthorizationCodeBinding? AuthorizationCodeBinding { get; init; } public static implicit operator UserInfo((int UserId, DateTimeOffset Expiry, string siteUrl) value) { diff --git a/src/sn-auth/ViewControllers/ForgottenPasswordController.cs b/src/sn-auth/ViewControllers/ForgottenPasswordController.cs index 2c642e0..40e504c 100644 --- a/src/sn-auth/ViewControllers/ForgottenPasswordController.cs +++ b/src/sn-auth/ViewControllers/ForgottenPasswordController.cs @@ -32,14 +32,12 @@ IOptions appOptions } [HttpGet("forgottenPassword")] - public IActionResult Index([FromQuery] string redirectUrl, [FromQuery] string callbackUri) + public IActionResult Index([FromQuery] ExternalLoginFlow flow) { - var model = new ForgottenPasswordViewModel + var model = flow.CopyTo(new ForgottenPasswordViewModel { - RedirectUrl = redirectUrl, - CallbackUri = callbackUri, RepositoryUrl = _sensenetSettings.Repository.Url, - }; + }); return View("Index", model); } @@ -47,7 +45,8 @@ public IActionResult Index([FromQuery] string redirectUrl, [FromQuery] string ca [HttpPost("forgottenPassword")] public async Task PostForgottenPassword() { - var model = new ForgottenPasswordViewModel(); + var flow = ExternalLoginFlow.FromForm(Request.Form); + var model = flow.CopyTo(new ForgottenPasswordViewModel()); if (_recaptchaService.IsConfigured() && !await _recaptchaService.ValidateRecaptchaAsync(Request.Form["g-recaptcha-response"].FirstOrDefault() ?? string.Empty)) { model.ErrorMessage = "Invalid ReCaptcha"; @@ -56,7 +55,7 @@ public async Task PostForgottenPassword() { try { - var uri = $"redirectUrl={Request.Form["RedirectUrl"]}&callbackUri={Request.Form["CallbackUri"]}"; + var uri = flow.QueryString.TrimStart('?'); await _authService.ForgottenPasswordAsync(new Models.ForgottenPasswordRequest { Email = Request.Form["Email"] @@ -65,8 +64,6 @@ await _authService.ForgottenPasswordAsync(new Models.ForgottenPasswordRequest catch { } } - model.RedirectUrl = Request.Form["RedirectUrl"]; - model.CallbackUri = Request.Form["CallbackUri"]; model.RepositoryUrl = _sensenetSettings.Repository.Url; if (string.IsNullOrEmpty(model.ErrorMessage)) model.SuccessMessage = "Email sent"; diff --git a/src/sn-auth/ViewControllers/LoginController.cs b/src/sn-auth/ViewControllers/LoginController.cs index d0f9041..d74752f 100644 --- a/src/sn-auth/ViewControllers/LoginController.cs +++ b/src/sn-auth/ViewControllers/LoginController.cs @@ -33,16 +33,14 @@ public LoginController( } [HttpGet("login")] - public IActionResult Index([FromQuery] string redirectUrl, [FromQuery] string callbackUri, [FromQuery] bool clearCookies) + public IActionResult Index([FromQuery] ExternalLoginFlow flow, [FromQuery] bool clearCookies) { - var model = new LoginViewModel + var model = flow.CopyTo(new LoginViewModel { IsRegistrationEnabled = _regSettings.IsEnabled, - RedirectUrl = redirectUrl, - CallbackUri = callbackUri, - IsHostInvalid = !_appSettings.AllowedHosts.Contains(redirectUrl), + IsHostInvalid = !flow.IsValid(_appSettings.AllowedHosts), RepositoryUrl = _sensenetSettings.Repository.Url, - }; + }); if (clearCookies && !string.IsNullOrEmpty(HttpContext.Request.Cookies["RememberMeToken"])) { @@ -62,9 +60,10 @@ public IActionResult Index([FromQuery] string redirectUrl, [FromQuery] string ca [HttpPost("login")] public async Task PostLogin() { - var model = new LoginViewModel(); + var flow = ExternalLoginFlow.FromForm(Request.Form); + var model = flow.CopyTo(new LoginViewModel()); LoginResponse? response = null; - if (!Request.Form.TryGetValue("RedirectUrl", out var redirectUrl) || !_appSettings.AllowedHosts.Contains(redirectUrl.FirstOrDefault())) + if (!flow.IsValid(_appSettings.AllowedHosts)) { model.IsHostInvalid = true; } @@ -82,7 +81,11 @@ public async Task PostLogin() LoginName = Request.Form["LoginName"].FirstOrDefault() ?? string.Empty, RememberMeToken = HttpContext.Request.Cookies["RememberMeToken"] ?? string.Empty, RememberMeRequested = Request.Form["RememberMe"].FirstOrDefault()?.ToLower() == "on", - SiteUrl = redirectUrl.FirstOrDefault() ?? string.Empty // Ensure SiteUrl is a valid URL + SiteUrl = flow.RedirectUrl, + State = flow.State, + CodeChallenge = flow.CodeChallenge, + CodeChallengeMethod = flow.CodeChallengeMethod, + CallbackUri = flow.CallbackUri }; response = await _authService.AuthenticateAsync(request, HttpContext.RequestAborted); } @@ -95,8 +98,6 @@ public async Task PostLogin() if (!string.IsNullOrEmpty(model.ErrorMessage) || model.IsHostInvalid) { model.IsRegistrationEnabled = _regSettings.IsEnabled; - model.RedirectUrl = redirectUrl; - model.CallbackUri = Request.Form["CallbackUri"].FirstOrDefault() ?? string.Empty; model.RepositoryUrl = _sensenetSettings.Repository.Url; model.IsRememberMeSet = !string.IsNullOrEmpty(HttpContext.Request.Cookies["RememberMeToken"]); model.RememberMeLoginName = HttpContext.Request.Cookies["RememberMeLoginName"] ?? string.Empty; @@ -111,20 +112,19 @@ public async Task PostLogin() if (response.MultiFactorRequired) { - var mfaViewModel = new MultiFactorViewModel + var mfaViewModel = flow.CopyTo(new MultiFactorViewModel { - RedirectUrl = redirectUrl, - CallbackUri = Request.Form["CallbackUri"].FirstOrDefault() ?? string.Empty, + RepositoryUrl = _sensenetSettings.Repository.Url, ManualEntryKey = response.ManualEntryKey, MultiFactorAuthToken = response.MultiFactorAuthToken, MultiFactorRequired = response.MultiFactorRequired, QrCodeSetupImageUrl = response.QrCodeSetupImageUrl - }; + }); return View("../MultiFactorAuth/Index", mfaViewModel); } else { - return Redirect($"{new Uri(new Uri(redirectUrl), Request.Form["CallbackUri"])}?auth_code={response.AuthToken}"); + return Redirect(flow.CallbackUrl(response)); } } } diff --git a/src/sn-auth/ViewControllers/MultiFactorAuthController.cs b/src/sn-auth/ViewControllers/MultiFactorAuthController.cs index 162c448..688e852 100644 --- a/src/sn-auth/ViewControllers/MultiFactorAuthController.cs +++ b/src/sn-auth/ViewControllers/MultiFactorAuthController.cs @@ -13,20 +13,26 @@ public class MultiFactorAuthController : Controller private readonly IAuthService _authService; private readonly IRecaptchaService _recaptchaService; private readonly SensenetSettings _sensenetSettings; + private readonly ApplicationSettings _appSettings; public MultiFactorAuthController( IRecaptchaService recaptchaService, IAuthService authService, - IOptions sensenetOptions) + IOptions sensenetOptions, + IOptions appOptions) { _recaptchaService = recaptchaService; _authService = authService; _sensenetSettings = sensenetOptions.Value; + _appSettings = appOptions.Value; } [HttpPost("multiFactorAuth")] public async Task PostTwoFactorAuth() { + var flow = ExternalLoginFlow.FromForm(Request.Form); + if (!flow.IsValid(_appSettings.AllowedHosts)) + return BadRequest("Invalid login callback"); var errorMessage = string.Empty; var isMultiFactorTokenExpired = false; if (_recaptchaService.IsConfigured() && !await _recaptchaService.ValidateRecaptchaAsync(Request.Form["g-recaptcha-response"].FirstOrDefault() ?? string.Empty)) @@ -41,11 +47,11 @@ public async Task PostTwoFactorAuth() { MultiFactorAuthToken = Request.Form["MultiFactorAuthToken"], MultiFactorCode = Request.Form["MultiFactorCode"], - SiteUrl = Request.Form["RedirectUrl"] + SiteUrl = flow.RedirectUrl }, HttpContext.RequestAborted, false); if (response != null) - return Redirect($"{new Uri(new Uri(Request.Form["RedirectUrl"]), Request.Form["CallbackUri"])}?auth_code={response.AuthToken}"); + return Redirect(flow.CallbackUrl(response)); } catch (BadRequestException ex) { @@ -57,26 +63,23 @@ public async Task PostTwoFactorAuth() if (isMultiFactorTokenExpired) { - return View("~/Views/Login/Index", new LoginViewModel + return View("~/Views/Login/Index.cshtml", flow.CopyTo(new LoginViewModel { - CallbackUri = Request.Form["CallbackUri"], - RedirectUrl = Request.Form["RedirectUrl"], - ErrorMessage = errorMessage - }); + RepositoryUrl = _sensenetSettings.Repository.Url, + ErrorMessage = "Login expired. Please sign in again." + })); } else { - return View("Index", new MultiFactorViewModel + return View("Index", flow.CopyTo(new MultiFactorViewModel { RepositoryUrl = _sensenetSettings.Repository.Url, - CallbackUri = Request.Form["CallbackUri"], - RedirectUrl = Request.Form["RedirectUrl"], MultiFactorAuthToken = Request.Form["MultiFactorAuthToken"], QrCodeSetupImageUrl = Request.Form["QrCodeSetupImageUrl"], ManualEntryKey = Request.Form["ManualEntryKey"], MultiFactorRequired = !string.IsNullOrEmpty(Request.Form["QrCodeSetupImageUrl"].FirstOrDefault()), ErrorMessage = errorMessage - }); + })); } } } diff --git a/src/sn-auth/ViewControllers/PasswordRecoveryController.cs b/src/sn-auth/ViewControllers/PasswordRecoveryController.cs index 5cfea6a..4e91427 100644 --- a/src/sn-auth/ViewControllers/PasswordRecoveryController.cs +++ b/src/sn-auth/ViewControllers/PasswordRecoveryController.cs @@ -24,21 +24,20 @@ IOptions sensenetOptions } [HttpGet("passwordRecovery")] - public IActionResult Index([FromQuery] string token, [FromQuery] string redirectUrl, [FromQuery] string callbackUri) + public IActionResult Index([FromQuery] string token, [FromQuery] ExternalLoginFlow flow) { - return View("Index", new PasswordRecoveryViewModel + return View("Index", flow.CopyTo(new PasswordRecoveryViewModel { RepositoryUrl = _sensenetSettings.Repository.Url, - CallbackUri = callbackUri, - RedirectUrl = redirectUrl, RecoveryToken = token - }); + })); } [HttpPost("passwordRecovery")] public async Task PostRecoveryToken() { - var model = new PasswordRecoveryViewModel(); + var flow = ExternalLoginFlow.FromForm(Request.Form); + var model = flow.CopyTo(new PasswordRecoveryViewModel()); if (Request.Form["Password"] != Request.Form["ConfirmPassword"]) { model.ErrorMessage = "Passwords mismatch"; @@ -66,14 +65,12 @@ await _authService.PasswordRecoveryAsync(new Models.PasswordRecoveryRequest if (!string.IsNullOrEmpty(model.ErrorMessage)) { model.RepositoryUrl = _sensenetSettings.Repository.Url; - model.RedirectUrl = Request.Form["RedirectUrl"]; - model.CallbackUri = Request.Form["CallbackUri"]; model.RecoveryToken = Request.Form["Token"]; return View("Index", model); } else { - return Redirect($"/Login?redirectUrl={Request.Form["RedirectUrl"]}&callbackUri={Request.Form["CallbackUri"]}"); + return Redirect($"/Login{flow.QueryString}"); } } } diff --git a/src/sn-auth/ViewControllers/RegistrationController.cs b/src/sn-auth/ViewControllers/RegistrationController.cs index f628c91..b03154c 100644 --- a/src/sn-auth/ViewControllers/RegistrationController.cs +++ b/src/sn-auth/ViewControllers/RegistrationController.cs @@ -34,18 +34,16 @@ IOptions appOptions } [HttpGet("registration")] - public IActionResult Index([FromQuery] string redirectUrl, [FromQuery] string callbackUri) + public IActionResult Index([FromQuery] ExternalLoginFlow flow) { if (!_regSettings.IsEnabled) - return Redirect($"/Login?redirectUrl={redirectUrl}&callbackUri={callbackUri}"); + return Redirect($"/Login{flow.QueryString}"); - var model = new RegistrationViewModel + var model = flow.CopyTo(new RegistrationViewModel { IsRegistrationEnabled = true, - RedirectUrl = redirectUrl, - CallbackUri = callbackUri, RepositoryUrl = _sensenetSettings.Repository.Url - }; + }); return View("Index", model); } @@ -53,11 +51,14 @@ public IActionResult Index([FromQuery] string redirectUrl, [FromQuery] string ca [HttpPost("registration")] public async Task PostRegistration() { - var model = new RegistrationViewModel(); - if (!Request.Form.TryGetValue("RedirectUrl", out var redirectUrl) || !_appSettings.AllowedHosts.Contains(redirectUrl.FirstOrDefault())) + var flow = ExternalLoginFlow.FromForm(Request.Form); + var model = flow.CopyTo(new RegistrationViewModel()); + if (!flow.IsValid(_appSettings.AllowedHosts)) model.IsHostInvalid = true; - if (Request.Form["Password"] != Request.Form["ConfirmPassword"]) + if (model.IsHostInvalid) + model.ErrorMessage = "Invalid login callback"; + else if (Request.Form["Password"] != Request.Form["ConfirmPassword"]) model.ErrorMessage = "Passwords mismatch"; else if (_recaptchaService.IsConfigured() && !await _recaptchaService.ValidateRecaptchaAsync(Request.Form["g-recaptcha-response"].FirstOrDefault() ?? string.Empty)) { @@ -93,22 +94,18 @@ await _authService.RegisterAsync(new Models.RegistrationRequest if (!string.IsNullOrEmpty(model.ErrorMessage) || model.IsHostInvalid) { model.IsRegistrationEnabled = true; - model.RedirectUrl = redirectUrl; - model.CallbackUri = Request.Form["CallbackUri"]; model.RepositoryUrl = _sensenetSettings.Repository.Url; return View("Index", model); } else { - var loginModel = new LoginViewModel + var loginModel = flow.CopyTo(new LoginViewModel { IsRegistrationEnabled = true, - RedirectUrl = redirectUrl, - CallbackUri = Request.Form["CallbackUri"], RepositoryUrl = _sensenetSettings.Repository.Url, IsHostInvalid = model.IsHostInvalid, SuccessMessage = "Successful registration" - }; + }); return View("../Login/Index", loginModel); } diff --git a/src/sn-auth/Views/ForgottenPassword/Index.cshtml b/src/sn-auth/Views/ForgottenPassword/Index.cshtml index 94e3e0a..269c607 100644 --- a/src/sn-auth/Views/ForgottenPassword/Index.cshtml +++ b/src/sn-auth/Views/ForgottenPassword/Index.cshtml @@ -6,7 +6,7 @@ @{ ViewBag.Title = "Sense/Net Auth - Forgotten password"; - var loginLink = $"/Login?redirectUrl={Model.RedirectUrl}&callbackUri={Model.CallbackUri}"; + var loginLink = $"/Login{Model.QueryString}"; var recaptcha = RecaptchaOptions.Value; var isRecaptchaConfigured = RecaptchaService.IsConfigured(); } @@ -41,6 +41,7 @@
+

@@ -82,4 +83,4 @@ } } -} \ No newline at end of file +} diff --git a/src/sn-auth/Views/Login/Index.cshtml b/src/sn-auth/Views/Login/Index.cshtml index e3fcb82..dc5f7ab 100644 --- a/src/sn-auth/Views/Login/Index.cshtml +++ b/src/sn-auth/Views/Login/Index.cshtml @@ -9,11 +9,11 @@ var redirectUrlHost = !string.IsNullOrEmpty(Model.RedirectUrl) ? new Uri(Model.RedirectUrl).Host.ToString() : null; - var queryParams = $"redirectUrl={Model.RedirectUrl}&callbackUri={Model.CallbackUri}"; - var registrationLink = $"/Registration?{queryParams}"; + var queryParams = Model.QueryString; + var registrationLink = $"/Registration{queryParams}"; var redirectBackUrl = $"{Model.RedirectUrl}?cancelledLogin=true"; - var forgotPasswordLink = $"/ForgottenPassword?{queryParams}"; - var clearCookiesLink = $"/Login?{queryParams}&clearCookies=true"; + var forgotPasswordLink = $"/ForgottenPassword{queryParams}"; + var clearCookiesLink = $"/Login{queryParams}&clearCookies=true"; var recaptcha = RecaptchaOptions.Value; var isRecaptchaConfigured = RecaptchaService.IsConfigured(); } @@ -50,6 +50,7 @@
+ @if (Model.IsRememberMeSet) {
@@ -121,4 +122,4 @@ } } -} \ No newline at end of file +} diff --git a/src/sn-auth/Views/MultiFactorAuth/Index.cshtml b/src/sn-auth/Views/MultiFactorAuth/Index.cshtml index 52d604d..3afd939 100644 --- a/src/sn-auth/Views/MultiFactorAuth/Index.cshtml +++ b/src/sn-auth/Views/MultiFactorAuth/Index.cshtml @@ -59,6 +59,7 @@
+ @@ -102,4 +103,4 @@ } } -} \ No newline at end of file +} diff --git a/src/sn-auth/Views/PasswordRecovery/Index.cshtml b/src/sn-auth/Views/PasswordRecovery/Index.cshtml index 7c66a23..f6814b5 100644 --- a/src/sn-auth/Views/PasswordRecovery/Index.cshtml +++ b/src/sn-auth/Views/PasswordRecovery/Index.cshtml @@ -6,7 +6,7 @@ @{ ViewBag.Title = "Sense/Net Auth - Password recovery"; - var loginLink = $"/Login?redirectUrl={Model.RedirectUrl}&callbackUri={Model.CallbackUri}"; + var loginLink = $"/Login{Model.QueryString}"; var recaptcha = RecaptchaOptions.Value; var isRecaptchaConfigured = RecaptchaService.IsConfigured(); } @@ -37,6 +37,7 @@
+

@@ -83,4 +84,4 @@ } } -} \ No newline at end of file +} diff --git a/src/sn-auth/Views/Registration/Index.cshtml b/src/sn-auth/Views/Registration/Index.cshtml index eb6a4f5..cc17cd4 100644 --- a/src/sn-auth/Views/Registration/Index.cshtml +++ b/src/sn-auth/Views/Registration/Index.cshtml @@ -12,7 +12,7 @@ var repositoryUrlHost = !string.IsNullOrEmpty(Model.RepositoryUrl) ? new Uri(Model.RepositoryUrl).Host.ToString() : null; - var loginLink = $"/Login?redirectUrl={Model.RedirectUrl}&callbackUri={Model.CallbackUri}"; + var loginLink = $"/Login{Model.QueryString}"; var recaptcha = RecaptchaOptions.Value; var isRecaptchaConfigured = RecaptchaService.IsConfigured(); } @@ -47,6 +47,7 @@
+

@@ -100,4 +101,4 @@ } } -} \ No newline at end of file +} diff --git a/src/sn-auth/Views/Shared/_ExternalLoginBinding.cshtml b/src/sn-auth/Views/Shared/_ExternalLoginBinding.cshtml new file mode 100644 index 0000000..225a29c --- /dev/null +++ b/src/sn-auth/Views/Shared/_ExternalLoginBinding.cshtml @@ -0,0 +1,4 @@ +@model SenseNetAuth.Models.ViewModels.ExternalLoginFlow + + + diff --git a/src/tests/IntegrationTests/AuthorizationCodeBindingTests.cs b/src/tests/IntegrationTests/AuthorizationCodeBindingTests.cs new file mode 100644 index 0000000..3d9ead3 --- /dev/null +++ b/src/tests/IntegrationTests/AuthorizationCodeBindingTests.cs @@ -0,0 +1,310 @@ +using Microsoft.Extensions.Options; +using Moq; +using SenseNetAuth.Infrastructure.Exceptions; +using SenseNetAuth.Models; +using SenseNetAuth.Models.Options; +using SenseNetAuth.Services; +using System.Security.Cryptography; +using System.Text; +using Xunit; + +namespace IntegrationTests; + +public class AuthorizationCodeBindingTests +{ + private const string Site = "https://adminui.test.sensenet.com"; + private const string Callback = Site + "/authentication/callback?repoUrl=https%3A%2F%2Frepo.example"; + private const string State = "a-valid-browser-state-with-32-characters"; + // RFC 7636 Appendix B: an independently specified S256 test vector. + private const string Verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private const string Challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + + [Fact] + public async Task BoundCodeReturnsTheOriginalFlowAndRequiresItsS256Proof() + { + var service = CreateService(); + var pending = await service.AuthenticateAsync(Request(), default); + + Assert.Equal(State, pending.State); + Assert.Equal(Callback, pending.CallbackUri); + Assert.NotEmpty(pending.AuthToken); + Assert.Empty(pending.AccessToken); + Assert.Empty(pending.RefreshToken); + var session = Convert(service, pending, Verifier); + Assert.True(service.ValidateToken(session.AccessToken)); + Assert.True(service.ValidateToken(service.RefreshToken(session.RefreshToken).AccessToken)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("wrong-but-valid-verifier-with-more-than-43-characters")] + [InlineData("short")] + [InlineData("invalid verifier containing spaces and more than 43 characters")] + [InlineData("invalid/verifier/containing/slashes/and/more/than/43/characters")] + public async Task MissingWrongOrMalformedProofDoesNotConsumeTheBoundCode(string? verifier) + { + var service = CreateService(); + var pending = await service.AuthenticateAsync(Request(), default); + + Assert.Throws(() => Convert(service, pending, verifier)); + Assert.True(service.ValidateToken(Convert(service, pending, Verifier).AccessToken)); + } + + [Theory] + [InlineData(43)] + [InlineData(128)] + public async Task AcceptsBothVerifierLengthLimitsAndUnreservedCharacters(int length) + { + var verifier = new string('~', length - 4) + "._-a"; + var request = Request(); + request.CodeChallenge = S256(verifier); + var service = CreateService(); + var pending = await service.AuthenticateAsync(request, default); + + Assert.True(service.ValidateToken(Convert(service, pending, verifier).AccessToken)); + } + + [Theory] + [InlineData(42)] + [InlineData(129)] + public async Task RejectsVerifierOutsideTheLengthLimitsEvenWhenItsHashMatches(int length) + { + var verifier = new string('a', length); + var request = Request(); + request.CodeChallenge = S256(verifier); + var service = CreateService(); + var pending = await service.AuthenticateAsync(request, default); + + Assert.Throws(() => Convert(service, pending, verifier)); + Assert.Throws(() => Convert(service, pending, null)); + } + + [Theory] + [InlineData("invalid verifier with spaces and at least 43 characters")] + [InlineData("invalid/verifier/with/slashes/and/at/least/43/characters")] + [InlineData("invalid+verifier+with+pluses+and+at+least+43+characters")] + public async Task RejectsNonUnreservedVerifierCharactersEvenWhenTheirHashMatches(string verifier) + { + var request = Request(); + request.CodeChallenge = S256(verifier); + var service = CreateService(); + var pending = await service.AuthenticateAsync(request, default); + + Assert.Throws(() => Convert(service, pending, verifier)); + } + + public static IEnumerable InvalidBindings() + { + yield return new object[] { "", Challenge, "S256" }; + yield return new object[] { State, "", "S256" }; + yield return new object[] { State, Challenge, "" }; + yield return new object[] { State, Challenge, "plain" }; + yield return new object[] { State, Challenge, "s256" }; + yield return new object[] { "too-short", Challenge, "S256" }; + yield return new object[] { new string('a', 513), Challenge, "S256" }; + yield return new object[] { new string('a', 32) + " ", Challenge, "S256" }; + yield return new object[] { State, new string('a', 42), "S256" }; + yield return new object[] { State, new string('a', 44), "S256" }; + yield return new object[] { State, new string('a', 42) + "+", "S256" }; + } + + [Theory] + [MemberData(nameof(InvalidBindings))] + public async Task PartialOrMalformedBindingsAreRejectedBeforeCredentialsAreChecked( + string state, string challenge, string method) + { + var users = new Mock(MockBehavior.Strict); + var service = CreateService(users: users); + var request = Request(); + request.State = state; + request.CodeChallenge = challenge; + request.CodeChallengeMethod = method; + + await Assert.ThrowsAsync(() => service.AuthenticateAsync(request, default)); + users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AProofCannotRedeemAnotherConcurrentBrowserFlow() + { + var service = CreateService(); + var first = await service.AuthenticateAsync(Request(), default); + var secondRequest = Request(); + var secondVerifier = new string('b', 64); + secondRequest.State = new string('b', 48); + secondRequest.CodeChallenge = S256(secondVerifier); + var second = await service.AuthenticateAsync(secondRequest, default); + + Assert.Throws(() => Convert(service, first, secondVerifier)); + Assert.Throws(() => Convert(service, second, Verifier)); + var firstSession = Convert(service, first, Verifier); + var secondSession = Convert(service, second, secondVerifier); + Assert.NotEqual(firstSession.AccessToken, secondSession.AccessToken); + Assert.True(service.ValidateToken(firstSession.AccessToken)); + Assert.True(service.ValidateToken(secondSession.AccessToken)); + } + + [Fact] + public async Task AProvenCodeCanBeRedeemedOnlyOnce() + { + var service = CreateService(); + var pending = await service.AuthenticateAsync(Request(), default); + var session = Convert(service, pending, Verifier); + + Assert.Throws(() => Convert(service, pending, Verifier)); + Assert.True(service.ValidateToken(session.AccessToken)); + } + + [Fact] + public async Task ConcurrentValidProofsCreateExactlyOneSession() + { + var service = CreateService(); + var pending = await service.AuthenticateAsync(Request(), default); + var attempts = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => + { + try { return Convert(service, pending, Verifier); } + catch (UnauthorizedException) { return null; } + }))); + + var winner = Assert.Single(attempts.Where(result => result != null))!; + Assert.True(service.ValidateToken(winner.AccessToken)); + Assert.True(service.ValidateToken(service.RefreshToken(winner.RefreshToken).AccessToken)); + } + + [Fact] + public async Task InvalidConcurrentProofsCannotBurnTheLegitimateCode() + { + var service = CreateService(); + var pending = await service.AuthenticateAsync(Request(), default); + await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => + Assert.Throws(() => Convert(service, pending, new string('x', 43)))))); + + Assert.True(service.ValidateToken(Convert(service, pending, Verifier).AccessToken)); + } + + [Fact] + public async Task MfaRetainsTheOriginalCodeBindingUntilProofConversion() + { + var service = CreateService(mfa: true); + var pending = await service.AuthenticateAsync(Request(), default); + Assert.True(pending.MultiFactorRequired); + Assert.Empty(pending.AuthToken); + Assert.Equal(State, pending.State); + Assert.Equal(Callback, pending.CallbackUri); + await Assert.ThrowsAsync(() => CompleteMfa(service, pending, code: "wrong")); + var completed = await CompleteMfa(service, pending); + + Assert.Equal(State, completed.State); + Assert.Equal(Callback, completed.CallbackUri); + Assert.Empty(completed.AccessToken); + Assert.Empty(completed.RefreshToken); + Assert.Throws(() => Convert(service, completed, null)); + Assert.Throws(() => Convert(service, completed, new string('x', 43))); + Assert.True(service.ValidateToken(Convert(service, completed, Verifier).AccessToken)); + await Assert.ThrowsAsync(() => CompleteMfa(service, pending)); + } + + [Fact] + public async Task BoundMfaCannotBypassProofThroughTheDirectLoginEndpoint() + { + var service = CreateService(mfa: true); + var pending = await service.AuthenticateAsync(Request(), default); + + await Assert.ThrowsAsync(() => CompleteMfa(service, pending, directLogin: true)); + var completed = await CompleteMfa(service, pending); + Assert.True(service.ValidateToken(Convert(service, completed, Verifier).AccessToken)); + } + + [Fact] + public async Task ConcurrentBoundMfaCompletionIssuesExactlyOneProofBoundCode() + { + var service = CreateService(mfa: true); + var pending = await service.AuthenticateAsync(Request(), default); + var attempts = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(async () => + { + try { return await CompleteMfa(service, pending); } + catch (BadRequestException) { return null; } + }))); + + var winner = Assert.Single(attempts.Where(result => result != null))!; + Assert.Equal(State, winner.State); + Assert.Equal(Callback, winner.CallbackUri); + Assert.Throws(() => Convert(service, winner, null)); + Assert.True(service.ValidateToken(Convert(service, winner, Verifier).AccessToken)); + } + + [Theory] + [InlineData("")] + [InlineData(Verifier)] + public async Task ProofBearingClientsRejectLegacyCodeDowngradesWithoutConsumingThem(string rejectedVerifier) + { + var service = CreateService(); + var pending = await service.AuthenticateAsync(LegacyRequest(), default); + + Assert.Throws(() => Convert(service, pending, rejectedVerifier)); + Assert.True(service.ValidateToken(Convert(service, pending, null).AccessToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacyClientsContinueToWorkWhenAllBindingFieldsAreOmitted(bool mfa) + { + var service = CreateService(mfa); + var pending = await service.AuthenticateAsync(LegacyRequest(), default); + if (mfa) pending = await CompleteMfa(service, pending); + + Assert.Null(pending.State); + Assert.Null(pending.CallbackUri); + Assert.True(service.ValidateToken(Convert(service, pending, null).AccessToken)); + } + + private static LoginRequest LegacyRequest() => new() + { + SiteUrl = Site, LoginName = "test-user", Password = "test-password" + }; + + private static LoginRequest Request() + { + var request = LegacyRequest(); + request.State = State; + request.CodeChallenge = Challenge; + request.CodeChallengeMethod = "S256"; + request.CallbackUri = Callback; + return request; + } + + private static LoginResponse Convert(AuthService service, LoginResponse pending, string? verifier) + => service.ConvertAuthToken(new TokenRequest { Token = pending.AuthToken, CodeVerifier = verifier }); + + private static Task CompleteMfa(AuthService service, LoginResponse pending, + bool directLogin = false, string code = "valid") => service.MultiFactorLoginAsync(new MultiFactorLoginRequest + { + SiteUrl = Site, MultiFactorAuthToken = pending.MultiFactorAuthToken, MultiFactorCode = code + }, default, directLogin); + + private static string S256(string verifier) => System.Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static AuthService CreateService(bool mfa = false, Mock? users = null) + { + if (users == null) + { + users = new Mock(MockBehavior.Strict); + users.Setup(u => u.ValidateCredentialsAsync("test-user", "test-password", It.IsAny())) + .ReturnsAsync(1); + users.Setup(u => u.GetMultiFactorAuthenticationInfoAsync(1, It.IsAny())) + .ReturnsAsync(new MultiFactorInfoResponse { MultiFactorEnabled = mfa, MultiFactorRegistered = true }); + users.Setup(u => u.ValidateTwoFactorCodeAsync(1, It.IsAny(), It.IsAny())) + .ReturnsAsync((int userId, string code, CancellationToken cancel) => code == "valid"); + } + return new AuthService(users.Object, Options.Create(new JwtSettings + { + Issuer = "authorization-code-binding-tests", Audience = "sensenet", + SecretKey = "test-only-signing-key-with-at-least-32-bytes", TokenExpiryMinutes = 60, + RefreshTokenExpiryDays = 15, AuthTokenExpiryMinutes = 5, MultiFactorAuthExpiryMinutes = 5 + }), Options.Create(new RegistrationSettings()), Options.Create(new PasswordRecoverySettings()), + Options.Create(new ApplicationSettings()), Mock.Of()); + } +} diff --git a/src/tests/IntegrationTests/ExternalLoginFlowTests.cs b/src/tests/IntegrationTests/ExternalLoginFlowTests.cs new file mode 100644 index 0000000..59e682d --- /dev/null +++ b/src/tests/IntegrationTests/ExternalLoginFlowTests.cs @@ -0,0 +1,207 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using SenseNetAuth.Models; +using SenseNetAuth.Services; +using Xunit; + +namespace IntegrationTests; + +public class ExternalLoginFlowTests : IClassFixture +{ + private const string State = "browser_state_012345678901234567890123456789"; + private const string Verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private const string Callback = "/authentication/login-callback?existing=one&next=two"; + private static readonly string Challenge = WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(Verifier))); + private readonly SensenetAuthApplicationFactory _factory; + + public ExternalLoginFlowTests(SensenetAuthApplicationFactory factory) => _factory = factory; + + private HttpClient Client(WebApplicationFactory? factory = null) => + (factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private static Dictionary Form() => new() + { + ["LoginName"] = "test", ["Password"] = "test", ["RedirectUrl"] = "https://localhost", + ["CallbackUri"] = Callback, ["State"] = State, ["CodeChallenge"] = Challenge, ["CodeChallengeMethod"] = "S256" + }; + + [Fact] + public async Task PasswordLoginEchoesStateAndPreservesCallbackQueryAndRequiresProof() + { + using var client = Client(); + var login = await client.PostAsync("/login", new FormUrlEncodedContent(Form())); + Assert.Equal(HttpStatusCode.Redirect, login.StatusCode); + var callback = login.Headers.Location!; + var query = QueryHelpers.ParseQuery(callback.Query); + Assert.Equal("/authentication/login-callback", callback.AbsolutePath); + Assert.Equal("one", query["existing"]); + Assert.Equal("two", query["next"]); + Assert.Equal(State, query["state"]); + var code = query["auth_code"].ToString(); + var wrong = await client.PostAsJsonAsync("/api/auth/convert-auth-token", new { token = code, codeVerifier = new string('x', 43) }); + Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); + var valid = await client.PostAsJsonAsync("/api/auth/convert-auth-token", new { token = code, codeVerifier = Verifier }); + Assert.Equal(HttpStatusCode.OK, valid.StatusCode); + var tokens = await valid.Content.ReadFromJsonAsync(); + Assert.False(string.IsNullOrEmpty(tokens!.AccessToken)); + var replay = await client.PostAsJsonAsync("/api/auth/convert-auth-token", new { token = code, codeVerifier = Verifier }); + Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); + } + + [Theory] + [InlineData("https://elsewhere.example/callback")] + [InlineData("//elsewhere.example/callback")] + [InlineData("http://localhost/callback")] + [InlineData("https://localhost:8080/callback")] + public async Task LoginDoesNotSendCodeToAnotherOrigin(string callback) + { + using var client = Client(); + var form = Form(); + form["CallbackUri"] = callback; + var response = await client.PostAsync("/login", new FormUrlEncodedContent(form)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Headers.Location); + Assert.DoesNotContain("name=\"Password\"", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task PartialBindingCannotSilentlyCreateALegacyCode() + { + using var client = Client(); + var form = Form(); + form.Remove("CodeChallengeMethod"); + var response = await client.PostAsync("/login", new FormUrlEncodedContent(form)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Headers.Location); + } + + [Fact] + public async Task NavigationAndValidationRetriesKeepEncodedBindingContext() + { + using var client = Client(); + var query = QueryString(Form()); + var loginHtml = await client.GetStringAsync("/login" + query); + Assert.Contains($"name=\"State\" value=\"{State}\"", loginHtml); + Assert.Contains("callbackUri=%2Fauthentication%2Flogin-callback%3Fexisting%3Done%26next%3Dtwo", loginHtml); + Assert.Contains("codeChallengeMethod=S256", loginHtml); + foreach (var path in new[] { "/forgottenPassword", "/passwordRecovery" }) + { + var html = await client.GetStringAsync(path + query); + Assert.Contains($"name=\"State\" value=\"{State}\"", html); + Assert.Contains($"name=\"CodeChallenge\" value=\"{Challenge}\"", html); + Assert.Contains("codeChallengeMethod=S256", html); + } + foreach (var path in new[] { "/registration", "/passwordRecovery" }) + { + var form = Form(); + form["Password"] = "one"; + form["ConfirmPassword"] = "two"; + var response = await client.PostAsync(path, new FormUrlEncodedContent(form)); + var html = await response.Content.ReadAsStringAsync(); + Assert.Contains($"name=\"State\" value=\"{State}\"", html); + Assert.Contains($"name=\"CodeChallenge\" value=\"{Challenge}\"", html); + } + var registration = await client.GetAsync("/registration" + query); + if (registration.StatusCode == HttpStatusCode.Redirect) + Assert.Equal(State, QueryHelpers.ParseQuery(new Uri(client.BaseAddress!, registration.Headers.Location!).Query)["state"]); + } + + [Fact] + public async Task ForgottenPasswordEmailContextKeepsStateAndEncodedCallback() + { + var service = new Mock(); + string? context = null; + service.Setup(s => s.ForgottenPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, value, _) => context = value) + .Returns(Task.CompletedTask); + using var factory = _factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(service.Object); + })); + using var client = Client(factory); + var form = Form(); + form["Email"] = "test@example.invalid"; + var response = await client.PostAsync("/forgottenPassword", new FormUrlEncodedContent(form)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(context); + var query = QueryHelpers.ParseQuery(context); + Assert.Equal(Callback, query["callbackUri"]); + Assert.Equal(State, query["state"]); + Assert.Equal(Challenge, query["codeChallenge"]); + } + + [Fact] + public async Task SuccessfulRecoveryRedirectKeepsTheBoundLoginContext() + { + var service = new Mock(); + service.Setup(s => s.PasswordRecoveryAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + using var factory = _factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(service.Object); + })); + using var client = Client(factory); + var form = Form(); + form["ConfirmPassword"] = form["Password"]; + form["Token"] = "synthetic-recovery-token"; + var response = await client.PostAsync("/passwordRecovery", new FormUrlEncodedContent(form)); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var destination = new Uri(client.BaseAddress!, response.Headers.Location!); + Assert.Equal("/Login", destination.AbsolutePath); + var query = QueryHelpers.ParseQuery(destination.Query); + Assert.Equal(Callback, query["callbackUri"]); + Assert.Equal(State, query["state"]); + Assert.Equal(Challenge, query["codeChallenge"]); + Assert.Equal("S256", query["codeChallengeMethod"]); + var login = await client.GetStringAsync(destination); + Assert.Contains($"name=\"State\" value=\"{State}\"", login); + } + + [Fact] + public async Task MfaUsesOriginalBoundCallbackAndStateAndKeepsBindingOnRetry() + { + var users = new Mock(); + users.Setup(s => s.ValidateCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(1); + users.Setup(s => s.GetMultiFactorAuthenticationInfoAsync(1, It.IsAny())) + .ReturnsAsync(new MultiFactorInfoResponse { MultiFactorEnabled = true, MultiFactorRegistered = true }); + users.Setup(s => s.ValidateTwoFactorCodeAsync(1, It.IsAny(), It.IsAny())) + .ReturnsAsync((int _, string code, CancellationToken _) => code == "123456"); + using var factory = _factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(users.Object); + })); + using var client = Client(factory); + var response = await client.PostAsync("/login", new FormUrlEncodedContent(Form())); + var html = await response.Content.ReadAsStringAsync(); + var token = System.Text.RegularExpressions.Regex.Match(html, "name=\"MultiFactorAuthToken\" value=\"([^\"]+)\"").Groups[1].Value; + Assert.NotEmpty(token); + var form = Form(); + form["MultiFactorAuthToken"] = token; + form["MultiFactorCode"] = "wrong"; + var retry = await client.PostAsync("/multiFactorAuth", new FormUrlEncodedContent(form)); + Assert.Contains($"name=\"State\" value=\"{State}\"", await retry.Content.ReadAsStringAsync()); + form["MultiFactorCode"] = "123456"; + form["State"] = new string('a', 43); + form["CallbackUri"] = "/tampered"; + var success = await client.PostAsync("/multiFactorAuth", new FormUrlEncodedContent(form)); + Assert.Equal(HttpStatusCode.Redirect, success.StatusCode); + Assert.Equal("/authentication/login-callback", success.Headers.Location!.AbsolutePath); + var query = QueryHelpers.ParseQuery(success.Headers.Location.Query); + Assert.Equal(State, query["state"]); + var exchange = await client.PostAsJsonAsync("/api/auth/convert-auth-token", new { token = query["auth_code"].ToString(), codeVerifier = Verifier }); + Assert.Equal(HttpStatusCode.OK, exchange.StatusCode); + } + + private static string QueryString(Dictionary form) => Microsoft.AspNetCore.Http.QueryString.Create( + form.Where(pair => pair.Key != "Password" && pair.Key != "LoginName").Select(pair => new KeyValuePair(pair.Key, pair.Value))).ToString(); +} diff --git a/src/tests/UnitTests/TemporaryCodeExpiryTests.cs b/src/tests/UnitTests/TemporaryCodeExpiryTests.cs new file mode 100644 index 0000000..fe47de1 --- /dev/null +++ b/src/tests/UnitTests/TemporaryCodeExpiryTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Options; +using SenseNetAuth.Models; +using SenseNetAuth.Models.Options; +using SenseNetAuth.TokenProviders; +using SenseNetAuth.TokenProviders.InMemory; + +namespace TestProject1; + +public class TemporaryCodeExpiryTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void TemporaryCodeExpirySettingsAreMinutesForBothCreationOverloads(bool mfa, bool includeSession) + { + var provider = CreateProvider(mfa, 3); + var binding = new AuthorizationCodeBinding(new string('s', 43), new string('c', 43), "https://admin.example/callback"); + var user = new UserInfo(1, DateTimeOffset.UtcNow.AddDays(1), "https://repo.example") + { + SessionId = "existing-session", AuthorizationCodeBinding = binding + }; + var before = DateTimeOffset.UtcNow; + var token = includeSession ? provider.CreateToken(user) : provider.CreateToken(user.UserId, user.SiteUrl); + var after = DateTimeOffset.UtcNow; + var saved = provider.GetValidUserInfo(token); + + Assert.True(saved.HasValue); + Assert.InRange(saved.Value.Expiry, before.AddMinutes(3), after.AddMinutes(3)); + Assert.Equal(user.UserId, saved.Value.UserId); + Assert.Equal(user.SiteUrl, saved.Value.SiteUrl); + if (includeSession) + { + Assert.Equal(user.SessionId, saved.Value.SessionId); + Assert.Equal(binding, saved.Value.AuthorizationCodeBinding); + } + Assert.NotNull(provider.ConsumeToken(token)); + Assert.Null(provider.ConsumeToken(token)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExpiredTemporaryCodesCannotBeValidatedOrConsumed(bool mfa) + { + var provider = CreateProvider(mfa, -1); + var checkedCode = provider.CreateToken(1, "https://repo.example"); + var consumedCode = provider.CreateToken(2, "https://repo.example"); + + Assert.False(provider.IsTokenValid(checkedCode)); + Assert.Null(provider.GetValidUserInfo(checkedCode)); + Assert.Null(provider.ConsumeToken(consumedCode)); + } + + private static InMemoryTokenProvider CreateProvider(bool mfa, int minutes) + { + var options = Options.Create(new JwtSettings + { + AuthTokenExpiryMinutes = mfa ? minutes + 9 : minutes, + MultiFactorAuthExpiryMinutes = mfa ? minutes : minutes + 9 + }); + return mfa ? new MultiFactorAuthTokenProvider(options) : new AuthTokenProvider(options); + } +}