From 9f19714c82f8124fe5d4ed9b0bd6af54d84946bf Mon Sep 17 00:00:00 2001 From: Jeff Mattson <18443511+sei-jmattson@users.noreply.github.com> Date: Fri, 10 Mar 2023 11:18:48 -0500 Subject: [PATCH] client certificate handling (#18) * support multiple cert headers * support x509 distinguished name parsing * remove cert header defaults --- .../Models/CertificateSubjectDetail.cs | 52 +++++++++++++++---- .../Options/AuthenticationOptions.cs | 10 ++-- src/IdentityServer/Api/AccountController.cs | 2 - src/IdentityServer/Api/ProfileController.cs | 9 ++-- .../Extensions/HttpRequestExtensions.cs | 36 +++++++------ .../Features/Account/Account/Login.cs | 1 + .../Features/Account/Account/Login.cshtml | 39 ++++++++++---- .../Features/Account/AccountController.cs | 13 ++--- .../Features/Account/AccountViewService.cs | 17 +++++- .../Features/Home/HomeController.cs | 6 ++- .../Features/Shared/_Layout.cshtml | 2 +- src/IdentityServer/appsettings.conf | 9 ++-- .../ServiceTests/CertificateTests.cs | 16 +++--- 13 files changed, 146 insertions(+), 66 deletions(-) diff --git a/src/Identity.Accounts/Models/CertificateSubjectDetail.cs b/src/Identity.Accounts/Models/CertificateSubjectDetail.cs index 403d5b0..0e50c7d 100644 --- a/src/Identity.Accounts/Models/CertificateSubjectDetail.cs +++ b/src/Identity.Accounts/Models/CertificateSubjectDetail.cs @@ -1,5 +1,5 @@ -// Copyright 2020 Carnegie Mellon University. -// Released under a MIT (SEI) license. See LICENSE.md in the project root. +// Copyright 2020 Carnegie Mellon University. +// Released under a MIT (SEI) license. See LICENSE.md in the project root. using System; using System.Collections.Generic; @@ -21,25 +21,44 @@ public class CertificateSubjectDetail // expecting ldap v3 distinguished names. https://tools.ietf.org/html/rfc2253 // That's what nginx 1.11+ passes + // now supporting x500 format, with it's inverse ordering and looser char constraints. public CertificateSubjectDetail(string subjectDN) { if (string.IsNullOrEmpty(subjectDN)) return; + char[] delimiters = new char[] {',', '+', ';'}; var rdns = new List(); int i = 0, j = 0; char[] chars = subjectDN.ToCharArray(); char last = '_'; + bool quoted = false; + bool escaped = false; + bool x500_ordering = subjectDN.IndexOf("O=") < subjectDN.LastIndexOf("OU="); + for (i = 0; i < chars.Length; i++) { - if ((chars[i] == ',' || chars[i] == '+') && last != '\\') + // close-quote + if (chars[i] == '"' && quoted) + quoted = false; + + // open-quote + if (chars[i] == '"' && last == '=') + quoted = true; + + // escaped + escaped = chars[i] != '\\' && last == '\\'; + + if (!quoted && !escaped && delimiters.Contains(chars[i])) { - rdns.Add(subjectDN.Substring(j, i-j)); + ParseMultiValueRDN(rdns, subjectDN.Substring(j, i-j).Trim()); j = i+1; } + last = chars[i]; } - rdns.Add(subjectDN.Substring(j)); + // last value + ParseMultiValueRDN(rdns, subjectDN.Substring(j).Trim()); this.Subject = subjectDN; this.IsAffiliate = Regex.IsMatch(subjectDN, "affiliate|contractor", RegexOptions.IgnoreCase); @@ -49,7 +68,7 @@ public CertificateSubjectDetail(string subjectDN) //if no externalid, parse from CN, dod-style if (String.IsNullOrEmpty(ExternalId) - && Int64.TryParse(nameParts.Last(), out long id)) + && Int64.TryParse(nameParts.Last(), out _)) { this.ExternalId = nameParts.Last(); } @@ -66,12 +85,16 @@ public CertificateSubjectDetail(string subjectDN) var ou = rdns.Where(x => x.StartsWith("OU=")).Select(x => x.Substring(3)); var o = rdns.Where(x => x.StartsWith("O=")).Select(x => x.Substring(2)); bool hasContext = false; - foreach (string s in ou) hasContext |= ExternalId.Contains(s); - foreach (string s in o) hasContext |= ExternalId.Contains(s); + foreach (string s in ou.Union(o)) hasContext |= ExternalId.Contains(s); + // foreach (string s in o) hasContext |= ExternalId.Contains(s); if (!hasContext) { + string suffix = x500_ordering + ? ou.FirstOrDefault() ?? o.FirstOrDefault() + : ou.LastOrDefault() ?? o.LastOrDefault() + ; DeprecatedExternalId = ExternalId; - ExternalId += "." + ou.LastOrDefault() ?? o.LastOrDefault(); + ExternalId += "." + suffix; } DisplayName = (nameParts.Length > 1) // assume dod-style @@ -87,5 +110,16 @@ public CertificateSubjectDetail(string subjectDN) this.DisplayName = String.Join(" ", nameParts).ToTitle(); this.UserName = this.DisplayName.ToAccountSlug(); } + + private void ParseMultiValueRDN(List list, string rdn) + { + int e = rdn.IndexOf('='); + string key = rdn.Substring(0, e); + string val = rdn.Substring(e+1).Replace("\"", ""); + string[] multi = val.Split('+'); + list.Add($"{key}={multi[0].Trim()}"); + foreach (string p in multi.Skip(1)) + list.Add(p); + } } } diff --git a/src/Identity.Accounts/Options/AuthenticationOptions.cs b/src/Identity.Accounts/Options/AuthenticationOptions.cs index e3548ab..89e6e1d 100644 --- a/src/Identity.Accounts/Options/AuthenticationOptions.cs +++ b/src/Identity.Accounts/Options/AuthenticationOptions.cs @@ -29,10 +29,10 @@ public class AuthenticationOptions public string SigningCertificate { get; set; } public string SigningCertificatePassword { get; set; } - public string ClientCertHeader { get; set; } = "X-ARR-ClientCert"; - public string ClientCertSubjectHeader { get; set; } = "ssl-client-subject-dn"; - public string ClientCertIssuerHeader { get; set; } = "ssl-client-issuer-dn"; - public string ClientCertVerifyHeader { get; set; } = "ssl-client-verify"; - public string ClientCertSerialHeader { get; set; } = "ssl-client-serial"; + public string ClientCertHeader { get; set; } + public string[] ClientCertSubjectHeaders { get; set; } = new string[] {}; + public string[] ClientCertIssuerHeaders { get; set; } = new string[] {}; + public string[] ClientCertSerialHeaders { get; set; } = new string[] {}; + public string[] ClientCertVerifyHeaders { get; set; } = new string[] {}; } } diff --git a/src/IdentityServer/Api/AccountController.cs b/src/IdentityServer/Api/AccountController.cs index c74200e..afd0b37 100644 --- a/src/IdentityServer/Api/AccountController.cs +++ b/src/IdentityServer/Api/AccountController.cs @@ -225,7 +225,6 @@ private string ResolveRecipients(Account[] list, string[] to) } [HttpGet("api/stats")] - [AllowAnonymous] [ProducesResponseType(typeof(AccountStats), 200)] public async Task GetStats([FromQuery] DateTime since) { @@ -233,7 +232,6 @@ public async Task GetStats([FromQuery] DateTime since) } [HttpGet("api/version")] - [AllowAnonymous] [ProducesResponseType(typeof(string), 200)] public IActionResult Version() { diff --git a/src/IdentityServer/Api/ProfileController.cs b/src/IdentityServer/Api/ProfileController.cs index f1dd853..fe7bb49 100644 --- a/src/IdentityServer/Api/ProfileController.cs +++ b/src/IdentityServer/Api/ProfileController.cs @@ -176,12 +176,12 @@ public async Task GetTokenSummary() CurrentCertificateIssuer = Request.GetCertificateIssuer( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertIssuerHeader + _options.Authentication.ClientCertIssuerHeaders ), CurrentCertificateSubject = Request.GetCertificateSubject( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertSubjectHeader + _options.Authentication.ClientCertSubjectHeaders ) }); @@ -194,7 +194,7 @@ public IActionResult GetCurrentCertSubject() { string subject = Request.GetCertificateSubject( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertSubjectHeader + _options.Authentication.ClientCertSubjectHeaders ); return Ok(subject); } @@ -214,8 +214,7 @@ public async Task SetCurrentCertSubject() { if (Request.HasValidatedSubject( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertSubjectHeader, - _options.Authentication.ClientCertVerifyHeader, + _options.Authentication.ClientCertSubjectHeaders, out subject) ){ await _svc.AddAccountValidatedSubject(User.GetSubjectId(), subject); diff --git a/src/IdentityServer/Extensions/HttpRequestExtensions.cs b/src/IdentityServer/Extensions/HttpRequestExtensions.cs index 6e3269c..4725c7c 100644 --- a/src/IdentityServer/Extensions/HttpRequestExtensions.cs +++ b/src/IdentityServer/Extensions/HttpRequestExtensions.cs @@ -18,7 +18,7 @@ public static bool HasCertificate( out X509Certificate2 cert ){ cert = request.HttpContext.Connection.ClientCertificate; - if (cert == null) + if (cert == null && !string.IsNullOrEmpty(header)) { string xcert = request.Headers[header]; if (!String.IsNullOrEmpty(xcert)) @@ -32,41 +32,47 @@ out X509Certificate2 cert public static bool HasValidatedSubject( this HttpRequest request, string certHeader, - string subjectHeader, - string verifyHeader, + string[] subjectHeaders, out string subject ){ - subject = request.GetCertificateSubject(certHeader, subjectHeader); - string verify = request.Headers[verifyHeader]; - return !string.IsNullOrEmpty(subject) && verify.StartsWith("success",StringComparison.OrdinalIgnoreCase); + subject = request.GetCertificateSubject(certHeader, subjectHeaders); + return string.IsNullOrEmpty(subject).Equals(false); } public static string GetCertificateSubject( this HttpRequest request, string certHeader, - string subjectHeader + string[] headers ){ if (request.HasCertificate(certHeader, out X509Certificate2 certificate2)) return certificate2.Subject; - if (string.IsNullOrEmpty(subjectHeader)) - return ""; - - return request.Headers[subjectHeader]; + return request.GetFirstHeaderValue(headers); } public static string GetCertificateIssuer( this HttpRequest request, string certHeader, - string issuerHeader + string[] headers ){ if (request.HasCertificate(certHeader, out X509Certificate2 certificate2)) return certificate2.Issuer; - if (string.IsNullOrEmpty(issuerHeader)) - return ""; + return request.GetFirstHeaderValue(headers); + } + + public static string GetFirstHeaderValue( + this HttpRequest request, + string[] headers + ){ + foreach(string header in headers) + { + string value = request.Headers[header]; + if (string.IsNullOrEmpty(value).Equals(false)) + return value; + } - return request.Headers[issuerHeader]; + return ""; } public static bool IsPrivileged(this ClaimsPrincipal user) diff --git a/src/IdentityServer/Features/Account/Account/Login.cs b/src/IdentityServer/Features/Account/Account/Login.cs index b32b16e..333e5fa 100644 --- a/src/IdentityServer/Features/Account/Account/Login.cs +++ b/src/IdentityServer/Features/Account/Account/Login.cs @@ -28,6 +28,7 @@ public class LoginViewModel : LoginModel public bool MSIE { get; set; } public string CertificateSubject { get; set; } public string CertificateIssuer { get; set; } + public string CertificateVerification { get; set; } } } diff --git a/src/IdentityServer/Features/Account/Account/Login.cshtml b/src/IdentityServer/Features/Account/Account/Login.cshtml index 2de00ad..3c18540 100644 --- a/src/IdentityServer/Features/Account/Account/Login.cshtml +++ b/src/IdentityServer/Features/Account/Account/Login.cshtml @@ -31,19 +31,38 @@ @if (String.IsNullOrEmpty(Model.CertificateSubject)) { -

- If expecting to use a certificate, you should - already have been prompted by your - browser to enter the PIN. If that is not the - case, please refer to - troubleshooting. -

+ @if(String.IsNullOrEmpty(Model.CertificateVerification)) + { +

+ If expecting to use a certificate, you should + already have been prompted by your + browser to enter the PIN. If that is not the + case, please refer to + troubleshooting. +

+ } + else + { +

+ Certificate Status: + @Model.CertificateVerification +

+ } } else { -

- @Model.CertificateSubject
-

+ + + + + + + + + + + +
Subject@Model.CertificateSubject
Issuer@Model.CertificateIssuer

diff --git a/src/IdentityServer/Features/Account/AccountController.cs b/src/IdentityServer/Features/Account/AccountController.cs index 9ebfee1..d204d08 100644 --- a/src/IdentityServer/Features/Account/AccountController.cs +++ b/src/IdentityServer/Features/Account/AccountController.cs @@ -91,8 +91,7 @@ public async Task Login(string returnUrl) if (Request.HasValidatedSubject( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertSubjectHeader, - _options.Authentication.ClientCertVerifyHeader, + _options.Authentication.ClientCertSubjectHeaders, out string subject) ){ return await LoginWithValidatedSubject(subject, returnUrl); @@ -126,8 +125,7 @@ public async Task Login(LoginModel model) if (Request.HasValidatedSubject( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertSubjectHeader, - _options.Authentication.ClientCertVerifyHeader, + _options.Authentication.ClientCertSubjectHeaders, out string subject) ){ return await LoginWithValidatedSubject(subject, model.ReturnUrl); @@ -807,7 +805,7 @@ public IActionResult Cert() { string result = Request.GetCertificateSubject( _options.Authentication.ClientCertHeader, - _options.Authentication.ClientCertSubjectHeader + _options.Authentication.ClientCertSubjectHeaders ); return Ok(result); @@ -815,7 +813,10 @@ public IActionResult Cert() private async Task Funregister() { - string tag = Request.Headers[_options.Authentication.ClientCertSubjectHeader]; + string tag = Request.GetCertificateSubject( + _options.Authentication.ClientCertHeader, + _options.Authentication.ClientCertSubjectHeaders + ); if (String.IsNullOrEmpty(tag)) tag = User?.FindFirstValue("sub"); if (String.IsNullOrEmpty(tag)) diff --git a/src/IdentityServer/Features/Account/AccountViewService.cs b/src/IdentityServer/Features/Account/AccountViewService.cs index 2684cee..1a60b14 100644 --- a/src/IdentityServer/Features/Account/AccountViewService.cs +++ b/src/IdentityServer/Features/Account/AccountViewService.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Identity.Accounts.Options; +using IdentityServer.Extensions; using IdentityServer.Models; using IdentityServer.Options; using IdentityServer.Services; @@ -68,6 +69,17 @@ public async Task GetLoginView(LoginModel model, int lockedSecon await Task.Delay(0); var headers = _httpContextAccessor.HttpContext.Request.Headers; + string subject = _httpContextAccessor.HttpContext.Request.GetCertificateSubject( + _options.Authentication.ClientCertHeader, + _options.Authentication.ClientCertSubjectHeaders + ); + string issuer = _httpContextAccessor.HttpContext.Request.GetCertificateIssuer( + _options.Authentication.ClientCertHeader, + _options.Authentication.ClientCertIssuerHeaders + ); + string verification = _httpContextAccessor.HttpContext.Request.GetFirstHeaderValue( + _options.Authentication.ClientCertVerifyHeaders + ); return new LoginViewModel() { AllowRememberLogin = _options.Authentication.AllowRememberLogin, @@ -79,8 +91,9 @@ public async Task GetLoginView(LoginModel model, int lockedSecon LockedSeconds = lockedSeconds, ExternalSchemes = _authOptions.ExternalOidc.Select(e => e.Scheme).ToArray(), MSIE = IsMSIE(headers[HeaderNames.UserAgent]), - CertificateSubject = headers[_options.Authentication.ClientCertSubjectHeader], - CertificateIssuer = headers[_options.Authentication.ClientCertIssuerHeader] + CertificateSubject = subject, + CertificateIssuer = issuer, + CertificateVerification = verification }; } public async Task GetPasswordView(PasswordModel model, int lockedSeconds = 0) diff --git a/src/IdentityServer/Features/Home/HomeController.cs b/src/IdentityServer/Features/Home/HomeController.cs index d61fb74..d9430b5 100644 --- a/src/IdentityServer/Features/Home/HomeController.cs +++ b/src/IdentityServer/Features/Home/HomeController.cs @@ -5,6 +5,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Identity.Accounts.Options; +using IdentityServer.Extensions; using IdentityServer.Models; using IdentityServer.Options; using Microsoft.AspNetCore.Authorization; @@ -56,7 +57,10 @@ public async Task fUnregister() model.UserAgent = Request.Headers[HeaderNames.UserAgent]; model.Subject = User?.FindFirstValue("sub") ?? Guid.NewGuid().ToString(); model.Name = User?.Identity?.Name ?? "Ender Wiggin"; - model.Certificate = Request.Headers[Options.Authentication.ClientCertSubjectHeader]; + model.Certificate = Request.GetCertificateSubject( + Options.Authentication.ClientCertHeader, + Options.Authentication.ClientCertSubjectHeaders + ); return View(model); } diff --git a/src/IdentityServer/Features/Shared/_Layout.cshtml b/src/IdentityServer/Features/Shared/_Layout.cshtml index ccb8dd8..7aa76b0 100644 --- a/src/IdentityServer/Features/Shared/_Layout.cshtml +++ b/src/IdentityServer/Features/Shared/_Layout.cshtml @@ -53,7 +53,7 @@ - + @RenderSection("scripts", required: false) diff --git a/src/IdentityServer/appsettings.conf b/src/IdentityServer/appsettings.conf index 5bdffdf..b7b8331 100644 --- a/src/IdentityServer/appsettings.conf +++ b/src/IdentityServer/appsettings.conf @@ -106,11 +106,12 @@ # Account__Authentication__SigningCertificatePassword = ## Header values for certificate data received from reverse proxy (i.e. nginx) +## ** These are NOT defaults. You must include your values. Nginx values are shown. # Account__Authentication__ClientCertHeader = X-ARR-ClientCert -# Account__Authentication__ClientCertSubjectHeader = ssl-client-subject-dn -# Account__Authentication__ClientCertIssuerHeader = ssl-client-issuer-dn -# Account__Authentication__ClientCertVerifyHeader = ssl-client-verify -# Account__Authentication__ClientCertSerialHeader = ssl-client-serial +# Account__Authentication__ClientCertSubjectHeaders__0 = ssl-client-subject-dn +# Account__Authentication__ClientCertIssuerHeaders__0 = ssl-client-issuer-dn +# Account__Authentication__ClientCertSerialHeaders__0 = ssl-client-serial +# Account__Authentication__ClientCertVerifyHeaders__0 = ssl-client-verify ## location of customized html for insertion into the referenced page # Account__Authentication__NoticeFile = wwwroot/html/notice.html diff --git a/test/Identity.Accounts.Test/ServiceTests/CertificateTests.cs b/test/Identity.Accounts.Test/ServiceTests/CertificateTests.cs index e23597e..7d5475c 100644 --- a/test/Identity.Accounts.Test/ServiceTests/CertificateTests.cs +++ b/test/Identity.Accounts.Test/ServiceTests/CertificateTests.cs @@ -11,11 +11,14 @@ public class CertificateTests : TestCore { [Theory] - [InlineData("CN=LAST.FIRST.MIDDLE.1234567890,OU=CONTRACTOR,OU=PKI,OU=DoD,O=U.S. Government,C=US", "first.last")] - [InlineData("CN=FIRST M LAST (affiliate)+UID=0754321000.DHS HQ,OU=People,OU=DHS HQ,OU=Department of Homeland Security,O=U.S. Government,C=US", "first.last")] - [InlineData("UID=0754321000.DHS HQ+CN=FIRST M LAST (affiliate),OU=People,OU=DHS HQ,OU=Department of Homeland Security,O=U.S. Government,C=US", "first.last")] - [InlineData("CN=FIRST LAST (Affiliate)+UID=89001234567890,OU=Department of Energy,O=U.S. Government,C=US", "first.last")] - [InlineData("UID=0123456789.DHS HQ+CN=FIRST I LAST,OU=People,OU=DHS HQ,OU=Department of Homeland Security,O=U.S. Government,C=US", "first.last")] + [InlineData("C=US, O=U.S. Government, OU=Department of Energy, CN=\"FIRST LAST (Affiliate)+UID=89001234567890\"", "first.last", "89001234567890.Department of Energy")] + [InlineData("C=US, O=U.S. Government, OU=Department of Homeland Security, OU=DHS HQ, OU=People, CN=\"FIRST M LAST (affiliate)+UID=0754321000.DHS HQ\"", "first.last", "0754321000.DHS HQ")] + [InlineData("C=US, O=U.S. Government, OU=DoD, OU=PKI, OU=CONTRACTOR, CN=LAST.FIRST.MIDDLE.1234567890", "first.last", "1234567890.DoD")] + [InlineData("CN=LAST.FIRST.MIDDLE.1234567890,OU=CONTRACTOR,OU=PKI,OU=DoD,O=U.S. Government,C=US", "first.last", "1234567890.DoD")] + [InlineData("CN=FIRST M LAST (affiliate)+UID=0754321000.DHS HQ,OU=People,OU=DHS HQ,OU=Department of Homeland Security,O=U.S. Government,C=US", "first.last", "0754321000.DHS HQ")] + [InlineData("UID=0754321000.DHS HQ+CN=FIRST M LAST (affiliate),OU=People,OU=DHS HQ,OU=Department of Homeland Security,O=U.S. Government,C=US", "first.last", "0754321000.DHS HQ")] + [InlineData("CN=FIRST LAST (Affiliate)+UID=89001234567890,OU=Department of Energy,O=U.S. Government,C=US", "first.last", "89001234567890.Department of Energy")] + [InlineData("UID=0123456789.DHS HQ+CN=FIRST I LAST,OU=People,OU=DHS HQ,OU=Department of Homeland Security,O=U.S. Government,C=US", "first.last", "0123456789.DHS HQ")] // [InlineData("O=ORG,OU=TEST,CN=FIRST M LAST-TWO", "first.last-two")] // [InlineData("O=ORG,OU=TEST,CN=FIRST M LAST TWO", "first.last.two")] // [InlineData("O=ORG,OU=TEST,CN=FIRST MIDDLE LAST TWO", "first.middle.last.two")] @@ -24,10 +27,11 @@ public class CertificateTests : TestCore // [InlineData("O=ORG,CN=FIRST M 'LAS'T,OU=TEST", "first.last")] // [InlineData("O=ORG,CN=FIRST M ORG'PO,OU=TEST", "first.orgpo")] // [InlineData("O=ORG,CN=FIRST M ORG'PO'DO,OU=TEST", "first.orgpodo")] - public void Subjects_Parse(string subject, string result) + public void Subjects_Parse(string subject, string result, string externalId) { var r = new CertificateSubjectDetail(subject); Assert.True(r.UserName == result); + Assert.True(r.ExternalId == externalId); } [Theory]