mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
PM-21024 ChangePasswordUri controller + service (#5845)
* 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 warnings
This commit is contained in:
89
src/Icons/Controllers/ChangePasswordUriController.cs
Normal file
89
src/Icons/Controllers/ChangePasswordUriController.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Icons/Models/ChangePasswordUriResponse.cs
Normal file
11
src/Icons/Models/ChangePasswordUriResponse.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Bit.Icons.Models;
|
||||||
|
|
||||||
|
public class ChangePasswordUriResponse
|
||||||
|
{
|
||||||
|
public string? uri { get; set; }
|
||||||
|
|
||||||
|
public ChangePasswordUriResponse(string? uri)
|
||||||
|
{
|
||||||
|
this.uri = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/Icons/Models/ChangePasswordUriSettings.cs
Normal file
8
src/Icons/Models/ChangePasswordUriSettings.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
89
src/Icons/Services/ChangePasswordUriService.cs
Normal file
89
src/Icons/Services/ChangePasswordUriService.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/Icons/Services/IChangePasswordUriService.cs
Normal file
6
src/Icons/Services/IChangePasswordUriService.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Icons.Services;
|
||||||
|
|
||||||
|
public interface IChangePasswordUriService
|
||||||
|
{
|
||||||
|
Task<string?> GetChangePasswordUri(string domain);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Icons.Extensions;
|
using Bit.Icons.Extensions;
|
||||||
|
using Bit.Icons.Models;
|
||||||
using Bit.SharedWeb.Utilities;
|
using Bit.SharedWeb.Utilities;
|
||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
@@ -27,8 +28,11 @@ public class Startup
|
|||||||
// Settings
|
// Settings
|
||||||
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
|
||||||
var iconsSettings = new IconsSettings();
|
var iconsSettings = new IconsSettings();
|
||||||
|
var changePasswordUriSettings = new ChangePasswordUriSettings();
|
||||||
ConfigurationBinder.Bind(Configuration.GetSection("IconsSettings"), iconsSettings);
|
ConfigurationBinder.Bind(Configuration.GetSection("IconsSettings"), iconsSettings);
|
||||||
|
ConfigurationBinder.Bind(Configuration.GetSection("ChangePasswordUriSettings"), changePasswordUriSettings);
|
||||||
services.AddSingleton(s => iconsSettings);
|
services.AddSingleton(s => iconsSettings);
|
||||||
|
services.AddSingleton(s => changePasswordUriSettings);
|
||||||
|
|
||||||
// Http client
|
// Http client
|
||||||
services.ConfigureHttpClients();
|
services.ConfigureHttpClients();
|
||||||
@@ -41,6 +45,10 @@ public class Startup
|
|||||||
{
|
{
|
||||||
options.SizeLimit = iconsSettings.CacheSizeLimit;
|
options.SizeLimit = iconsSettings.CacheSizeLimit;
|
||||||
});
|
});
|
||||||
|
services.AddMemoryCache(options =>
|
||||||
|
{
|
||||||
|
options.SizeLimit = changePasswordUriSettings.CacheSizeLimit;
|
||||||
|
});
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
services.AddServices();
|
services.AddServices();
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ public static class ServiceCollectionExtension
|
|||||||
AllowAutoRedirect = false,
|
AllowAutoRedirect = false,
|
||||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The CreatePasswordUri handler wants similar headers as Icons to portray coming from a browser but
|
||||||
|
// needs to follow redirects to get the final URL.
|
||||||
|
services.AddHttpClient("ChangePasswordUri", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(20);
|
||||||
|
client.MaxResponseContentBufferSize = 5000000; // 5 MB
|
||||||
|
// Let's add some headers to look like we're coming from a web browser request. Some websites
|
||||||
|
// will block our request without these.
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36");
|
||||||
|
client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.8");
|
||||||
|
client.DefaultRequestHeaders.Add("Cache-Control", "no-cache");
|
||||||
|
client.DefaultRequestHeaders.Add("Pragma", "no-cache");
|
||||||
|
}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddHtmlParsing(this IServiceCollection services)
|
public static void AddHtmlParsing(this IServiceCollection services)
|
||||||
@@ -40,5 +58,6 @@ public static class ServiceCollectionExtension
|
|||||||
services.AddSingleton<IUriService, UriService>();
|
services.AddSingleton<IUriService, UriService>();
|
||||||
services.AddSingleton<IDomainMappingService, DomainMappingService>();
|
services.AddSingleton<IDomainMappingService, DomainMappingService>();
|
||||||
services.AddSingleton<IIconFetchingService, IconFetchingService>();
|
services.AddSingleton<IIconFetchingService, IconFetchingService>();
|
||||||
|
services.AddSingleton<IChangePasswordUriService, ChangePasswordUriService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,10 @@
|
|||||||
"cacheEnabled": true,
|
"cacheEnabled": true,
|
||||||
"cacheHours": 24,
|
"cacheHours": 24,
|
||||||
"cacheSizeLimit": null
|
"cacheSizeLimit": null
|
||||||
|
},
|
||||||
|
"changePasswordUriSettings": {
|
||||||
|
"cacheEnabled": true,
|
||||||
|
"cacheHours": 24,
|
||||||
|
"cacheSizeLimit": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
test/Icons.Test/Services/ChangePasswordUriServiceTests.cs
Normal file
108
test/Icons.Test/Services/ChangePasswordUriServiceTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user