Browse Source
* add ChangePasswordUri controller and service to Icons * add individual settings for change password uri * add logging to change password uri controller * use custom http client that follows redirects * add ChangePasswordUriService tests * remove unneeded null check * fix copy pasta - changePasswordUriSettings * add `HelpUsersUpdatePasswords` policy * Remove policy for change password uri - this was removed from scope * fix nullable warningspull/6250/head
9 changed files with 343 additions and 0 deletions
@ -0,0 +1,89 @@ |
|||||||
|
using Bit.Icons.Models; |
||||||
|
using Bit.Icons.Services; |
||||||
|
using Microsoft.AspNetCore.Mvc; |
||||||
|
using Microsoft.Extensions.Caching.Memory; |
||||||
|
|
||||||
|
namespace Bit.Icons.Controllers; |
||||||
|
|
||||||
|
[Route("change-password-uri")] |
||||||
|
public class ChangePasswordUriController : Controller |
||||||
|
{ |
||||||
|
private readonly IMemoryCache _memoryCache; |
||||||
|
private readonly IDomainMappingService _domainMappingService; |
||||||
|
private readonly IChangePasswordUriService _changePasswordService; |
||||||
|
private readonly ChangePasswordUriSettings _changePasswordSettings; |
||||||
|
private readonly ILogger<ChangePasswordUriController> _logger; |
||||||
|
|
||||||
|
public ChangePasswordUriController( |
||||||
|
IMemoryCache memoryCache, |
||||||
|
IDomainMappingService domainMappingService, |
||||||
|
IChangePasswordUriService changePasswordService, |
||||||
|
ChangePasswordUriSettings changePasswordUriSettings, |
||||||
|
ILogger<ChangePasswordUriController> logger) |
||||||
|
{ |
||||||
|
_memoryCache = memoryCache; |
||||||
|
_domainMappingService = domainMappingService; |
||||||
|
_changePasswordService = changePasswordService; |
||||||
|
_changePasswordSettings = changePasswordUriSettings; |
||||||
|
_logger = logger; |
||||||
|
} |
||||||
|
|
||||||
|
[HttpGet("config")] |
||||||
|
public IActionResult GetConfig() |
||||||
|
{ |
||||||
|
return new JsonResult(new |
||||||
|
{ |
||||||
|
_changePasswordSettings.CacheEnabled, |
||||||
|
_changePasswordSettings.CacheHours, |
||||||
|
_changePasswordSettings.CacheSizeLimit |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
[HttpGet] |
||||||
|
public async Task<IActionResult> Get([FromQuery] string uri) |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(uri)) |
||||||
|
{ |
||||||
|
return new BadRequestResult(); |
||||||
|
} |
||||||
|
|
||||||
|
var uriHasProtocol = uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || |
||||||
|
uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase); |
||||||
|
|
||||||
|
var url = uriHasProtocol ? uri : $"https://{uri}"; |
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var validUri)) |
||||||
|
{ |
||||||
|
return new BadRequestResult(); |
||||||
|
} |
||||||
|
|
||||||
|
var domain = validUri.Host; |
||||||
|
|
||||||
|
var mappedDomain = _domainMappingService.MapDomain(domain); |
||||||
|
if (!_changePasswordSettings.CacheEnabled || !_memoryCache.TryGetValue(mappedDomain, out string? changePasswordUri)) |
||||||
|
{ |
||||||
|
var result = await _changePasswordService.GetChangePasswordUri(domain); |
||||||
|
if (result == null) |
||||||
|
{ |
||||||
|
_logger.LogWarning("Null result returned for {0}.", domain); |
||||||
|
changePasswordUri = null; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
changePasswordUri = result; |
||||||
|
} |
||||||
|
|
||||||
|
if (_changePasswordSettings.CacheEnabled) |
||||||
|
{ |
||||||
|
_logger.LogInformation("Cache uri for {0}.", domain); |
||||||
|
_memoryCache.Set(mappedDomain, changePasswordUri, new MemoryCacheEntryOptions |
||||||
|
{ |
||||||
|
AbsoluteExpirationRelativeToNow = new TimeSpan(_changePasswordSettings.CacheHours, 0, 0), |
||||||
|
Size = changePasswordUri?.Length ?? 0, |
||||||
|
Priority = changePasswordUri == null ? CacheItemPriority.High : CacheItemPriority.Normal |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return Ok(new ChangePasswordUriResponse(changePasswordUri)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
namespace Bit.Icons.Models; |
||||||
|
|
||||||
|
public class ChangePasswordUriResponse |
||||||
|
{ |
||||||
|
public string? uri { get; set; } |
||||||
|
|
||||||
|
public ChangePasswordUriResponse(string? uri) |
||||||
|
{ |
||||||
|
this.uri = uri; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
namespace Bit.Icons.Models; |
||||||
|
|
||||||
|
public class ChangePasswordUriSettings |
||||||
|
{ |
||||||
|
public virtual bool CacheEnabled { get; set; } |
||||||
|
public virtual int CacheHours { get; set; } |
||||||
|
public virtual long? CacheSizeLimit { get; set; } |
||||||
|
} |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
namespace Bit.Icons.Services; |
||||||
|
|
||||||
|
public class ChangePasswordUriService : IChangePasswordUriService |
||||||
|
{ |
||||||
|
private readonly HttpClient _httpClient; |
||||||
|
|
||||||
|
public ChangePasswordUriService(IHttpClientFactory httpClientFactory) |
||||||
|
{ |
||||||
|
_httpClient = httpClientFactory.CreateClient("ChangePasswordUri"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Fetches the well-known change password URL for the given domain. |
||||||
|
/// </summary> |
||||||
|
/// <param name="domain"></param> |
||||||
|
/// <returns></returns> |
||||||
|
public async Task<string?> GetChangePasswordUri(string domain) |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(domain)) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
var hasReliableStatusCode = await HasReliableHttpStatusCode(domain); |
||||||
|
var wellKnownChangePasswordUrl = await GetWellKnownChangePasswordUrl(domain); |
||||||
|
|
||||||
|
|
||||||
|
if (hasReliableStatusCode && wellKnownChangePasswordUrl != null) |
||||||
|
{ |
||||||
|
return wellKnownChangePasswordUrl; |
||||||
|
} |
||||||
|
|
||||||
|
// Reliable well-known URL criteria not met, return null |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Checks if the server returns a non-200 status code for a resource that should not exist. |
||||||
|
// See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics |
||||||
|
/// </summary> |
||||||
|
/// <param name="urlDomain">The domain of the URL to check</param> |
||||||
|
/// <returns>True when the domain responds with a non-ok response</returns> |
||||||
|
private async Task<bool> HasReliableHttpStatusCode(string urlDomain) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var url = new UriBuilder(urlDomain) |
||||||
|
{ |
||||||
|
Path = "/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200" |
||||||
|
}; |
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); |
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request); |
||||||
|
return !response.IsSuccessStatusCode; |
||||||
|
} |
||||||
|
catch |
||||||
|
{ |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response |
||||||
|
/// is returned. Returns null if the request throws or the response is not 200 OK. |
||||||
|
/// See https://w3c.github.io/webappsec-change-password-url/ |
||||||
|
/// </summary> |
||||||
|
/// <param name="urlDomain">The domain of the URL to check</param> |
||||||
|
/// <returns>The well-known change password URL if valid, otherwise null</returns> |
||||||
|
private async Task<string?> GetWellKnownChangePasswordUrl(string urlDomain) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var url = new UriBuilder(urlDomain) |
||||||
|
{ |
||||||
|
Path = "/.well-known/change-password" |
||||||
|
}; |
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); |
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request); |
||||||
|
return response.IsSuccessStatusCode ? url.ToString() : null; |
||||||
|
} |
||||||
|
catch |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
namespace Bit.Icons.Services; |
||||||
|
|
||||||
|
public interface IChangePasswordUriService |
||||||
|
{ |
||||||
|
Task<string?> GetChangePasswordUri(string domain); |
||||||
|
} |
||||||
@ -0,0 +1,108 @@ |
|||||||
|
using System.Net; |
||||||
|
using Bit.Icons.Services; |
||||||
|
using Bit.Test.Common.MockedHttpClient; |
||||||
|
using NSubstitute; |
||||||
|
using Xunit; |
||||||
|
|
||||||
|
namespace Bit.Icons.Test.Services; |
||||||
|
|
||||||
|
public class ChangePasswordUriServiceTests : ServiceTestBase<ChangePasswordUriService> |
||||||
|
{ |
||||||
|
[Theory] |
||||||
|
[InlineData("https://example.com", "https://example.com:443/.well-known/change-password")] |
||||||
|
public async Task GetChangePasswordUri_WhenBothChecksPass_ReturnsWellKnownUrl(string domain, string expectedUrl) |
||||||
|
{ |
||||||
|
// Arrange |
||||||
|
var mockedHandler = new MockedHttpMessageHandler(); |
||||||
|
|
||||||
|
var nonExistentUrl = $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200"; |
||||||
|
var changePasswordUrl = $"{domain}/.well-known/change-password"; |
||||||
|
|
||||||
|
// Mock the response for the resource-that-should-not-exist request (returns 404) |
||||||
|
mockedHandler |
||||||
|
.When(nonExistentUrl) |
||||||
|
.RespondWith(HttpStatusCode.NotFound) |
||||||
|
.WithContent(new StringContent("Not found")); |
||||||
|
|
||||||
|
// Mock the response for the change-password request (returns 200) |
||||||
|
mockedHandler |
||||||
|
.When(changePasswordUrl) |
||||||
|
.RespondWith(HttpStatusCode.OK) |
||||||
|
.WithContent(new StringContent("Ok")); |
||||||
|
|
||||||
|
var mockHttpFactory = Substitute.For<IHttpClientFactory>(); |
||||||
|
mockHttpFactory.CreateClient("ChangePasswordUri").Returns(mockedHandler.ToHttpClient()); |
||||||
|
|
||||||
|
var service = new ChangePasswordUriService(mockHttpFactory); |
||||||
|
|
||||||
|
var result = await service.GetChangePasswordUri(domain); |
||||||
|
|
||||||
|
Assert.Equal(expectedUrl, result); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory] |
||||||
|
[InlineData("https://example.com")] |
||||||
|
public async Task GetChangePasswordUri_WhenResourceThatShouldNotExistReturns200_ReturnsNull(string domain) |
||||||
|
{ |
||||||
|
var mockHttpFactory = Substitute.For<IHttpClientFactory>(); |
||||||
|
var mockedHandler = new MockedHttpMessageHandler(); |
||||||
|
|
||||||
|
mockedHandler |
||||||
|
.When(HttpMethod.Get, $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200") |
||||||
|
.RespondWith(HttpStatusCode.OK) |
||||||
|
.WithContent(new StringContent("Ok")); |
||||||
|
|
||||||
|
mockedHandler |
||||||
|
.When(HttpMethod.Get, $"{domain}/.well-known/change-password") |
||||||
|
.RespondWith(HttpStatusCode.OK) |
||||||
|
.WithContent(new StringContent("Ok")); |
||||||
|
|
||||||
|
var httpClient = mockedHandler.ToHttpClient(); |
||||||
|
mockHttpFactory.CreateClient("ChangePasswordUri").Returns(httpClient); |
||||||
|
|
||||||
|
var service = new ChangePasswordUriService(mockHttpFactory); |
||||||
|
|
||||||
|
var result = await service.GetChangePasswordUri(domain); |
||||||
|
|
||||||
|
Assert.Null(result); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory] |
||||||
|
[InlineData("https://example.com")] |
||||||
|
public async Task GetChangePasswordUri_WhenChangePasswordUrlNotFound_ReturnsNull(string domain) |
||||||
|
{ |
||||||
|
var mockHttpFactory = Substitute.For<IHttpClientFactory>(); |
||||||
|
var mockedHandler = new MockedHttpMessageHandler(); |
||||||
|
|
||||||
|
mockedHandler |
||||||
|
.When(HttpMethod.Get, $"{domain}/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200") |
||||||
|
.RespondWith(HttpStatusCode.NotFound) |
||||||
|
.WithContent(new StringContent("Not found")); |
||||||
|
|
||||||
|
mockedHandler |
||||||
|
.When(HttpMethod.Get, $"{domain}/.well-known/change-password") |
||||||
|
.RespondWith(HttpStatusCode.NotFound) |
||||||
|
.WithContent(new StringContent("Not found")); |
||||||
|
|
||||||
|
var httpClient = mockedHandler.ToHttpClient(); |
||||||
|
mockHttpFactory.CreateClient("ChangePasswordUri").Returns(httpClient); |
||||||
|
|
||||||
|
var service = new ChangePasswordUriService(mockHttpFactory); |
||||||
|
|
||||||
|
var result = await service.GetChangePasswordUri(domain); |
||||||
|
|
||||||
|
Assert.Null(result); |
||||||
|
} |
||||||
|
|
||||||
|
[Theory] |
||||||
|
[InlineData("")] |
||||||
|
public async Task GetChangePasswordUri_WhenDomainIsNullOrEmpty_ReturnsNull(string domain) |
||||||
|
{ |
||||||
|
var mockHttpFactory = Substitute.For<IHttpClientFactory>(); |
||||||
|
var service = new ChangePasswordUriService(mockHttpFactory); |
||||||
|
|
||||||
|
var result = await service.GetChangePasswordUri(domain); |
||||||
|
|
||||||
|
Assert.Null(result); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue