Skip to content

Commit

Permalink
client certificate handling (#18)
Browse files Browse the repository at this point in the history
* support multiple cert headers
* support x509 distinguished name parsing
* remove cert header defaults
  • Loading branch information
sei-jmattson authored Mar 10, 2023
1 parent 2b72c7f commit 9f19714
Show file tree
Hide file tree
Showing 13 changed files with 146 additions and 66 deletions.
52 changes: 43 additions & 9 deletions src/Identity.Accounts/Models/CertificateSubjectDetail.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string>();
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);
Expand All @@ -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();
}
Expand All @@ -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
Expand All @@ -87,5 +110,16 @@ public CertificateSubjectDetail(string subjectDN)
this.DisplayName = String.Join(" ", nameParts).ToTitle();
this.UserName = this.DisplayName.ToAccountSlug();
}

private void ParseMultiValueRDN(List<string> 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);
}
}
}
10 changes: 5 additions & 5 deletions src/Identity.Accounts/Options/AuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {};
}
}
2 changes: 0 additions & 2 deletions src/IdentityServer/Api/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,15 +225,13 @@ private string ResolveRecipients(Account[] list, string[] to)
}

[HttpGet("api/stats")]
[AllowAnonymous]
[ProducesResponseType(typeof(AccountStats), 200)]
public async Task<IActionResult> GetStats([FromQuery] DateTime since)
{
return Ok(await _svc.GetStats(since));
}

[HttpGet("api/version")]
[AllowAnonymous]
[ProducesResponseType(typeof(string), 200)]
public IActionResult Version()
{
Expand Down
9 changes: 4 additions & 5 deletions src/IdentityServer/Api/ProfileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,12 @@ public async Task<IActionResult> 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
)
});

Expand All @@ -194,7 +194,7 @@ public IActionResult GetCurrentCertSubject()
{
string subject = Request.GetCertificateSubject(
_options.Authentication.ClientCertHeader,
_options.Authentication.ClientCertSubjectHeader
_options.Authentication.ClientCertSubjectHeaders
);
return Ok(subject);
}
Expand All @@ -214,8 +214,7 @@ public async Task<IActionResult> SetCurrentCertSubject()
{
if (Request.HasValidatedSubject(
_options.Authentication.ClientCertHeader,
_options.Authentication.ClientCertSubjectHeader,
_options.Authentication.ClientCertVerifyHeader,
_options.Authentication.ClientCertSubjectHeaders,
out subject)
){
await _svc.AddAccountValidatedSubject(User.GetSubjectId(), subject);
Expand Down
36 changes: 21 additions & 15 deletions src/IdentityServer/Extensions/HttpRequestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/IdentityServer/Features/Account/Account/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

}
39 changes: 29 additions & 10 deletions src/IdentityServer/Features/Account/Account/Login.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,38 @@

@if (String.IsNullOrEmpty(Model.CertificateSubject))
{
<p>
If expecting to use a certificate, you should
<strong>already</strong> have been prompted by your
browser to enter the PIN. If that is not the
case, please refer to
<a asp-action="trouble">troubleshooting</a>.
</p>
@if(String.IsNullOrEmpty(Model.CertificateVerification))
{
<p>
If expecting to use a certificate, you should
<strong>already</strong> have been prompted by your
browser to enter the PIN. If that is not the
case, please refer to
<a asp-action="trouble">troubleshooting</a>.
</p>
}
else
{
<p aria-label="client certificate verification">
<span>Certificate Status:</span>
<em>@Model.CertificateVerification</em>
</p>
}
}
else
{
<p aria-label="client certificate subject">
<em>@Model.CertificateSubject</em><br/>
</p>
<table class="table my-2 text-left" aria-label="client certificate subject">
<tbody>
<tr>
<th>Subject</th>
<td><small>@Model.CertificateSubject</small></td>
</tr>
<tr>
<th>Issuer</th>
<td><small>@Model.CertificateIssuer</small></td>
</tr>
</tbody>
</table>
<p>
<form asp-route="Login">
<input id="cert-ReturnUrl" type="hidden" asp-for="ReturnUrl"/>
Expand Down
13 changes: 7 additions & 6 deletions src/IdentityServer/Features/Account/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -126,8 +125,7 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -807,15 +805,18 @@ public IActionResult Cert()
{
string result = Request.GetCertificateSubject(
_options.Authentication.ClientCertHeader,
_options.Authentication.ClientCertSubjectHeader
_options.Authentication.ClientCertSubjectHeaders
);

return Ok(result);
}

private async Task<IActionResult> 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))
Expand Down
17 changes: 15 additions & 2 deletions src/IdentityServer/Features/Account/AccountViewService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,6 +69,17 @@ public async Task<LoginViewModel> 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,
Expand All @@ -79,8 +91,9 @@ public async Task<LoginViewModel> 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<PasswordViewModel> GetPasswordView(PasswordModel model, int lockedSeconds = 0)
Expand Down
6 changes: 5 additions & 1 deletion src/IdentityServer/Features/Home/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,7 +57,10 @@ public async Task<IActionResult> 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);
}

Expand Down
Loading

0 comments on commit 9f19714

Please sign in to comment.