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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Icons.Services; |
||||
|
||||
public interface IChangePasswordUriService |
||||
{ |
||||
Task<string?> GetChangePasswordUri(string domain); |
||||
} |
||||
@ -0,0 +1,108 @@
@@ -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