Skip to content

Instantly share code, notes, and snippets.

@synesthesia
Created October 2, 2018 18:20
Show Gist options
  • Save synesthesia/4b3173e1b26aaa66ed40d649f125ca4a to your computer and use it in GitHub Desktop.
Save synesthesia/4b3173e1b26aaa66ed40d649f125ca4a to your computer and use it in GitHub Desktop.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Security;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using System.Web.Mvc;
using BrockAllen.MembershipReboot.WebHost;
using Castle.Core.Logging;
using IdentityServer3.Core.Extensions;
using ZZZ.Identity.Services.Membership.Services;
using ZZZ.Identity.Infrastructure.Common;
using ZZZ.Identity.Services.Membership.Model;
namespace ZZZ.Identity.Controllers
{
/// <summary>
/// Provides custom SSO endpoint for Discourse
/// </summary>
/// <seealso cref="https://blogg.blank.no/enabling-sso-for-discourse-with-identityserver3-7da2aca64bab"/>
public class DiscourseController : Controller
{
private readonly CustomUserAccountService _userAccountService;
private readonly ILogger _logger;
private readonly string _secret = ConfigurationHelper.DiscourseSsoSecret;
public DiscourseController(CustomUserAccountService userAccountService, ILogger logger)
{
_userAccountService = userAccountService ?? throw new ArgumentNullException(nameof(userAccountService));
_logger = logger;
}
[Route("identity/discourse")]
[HttpGet]
public async Task<ActionResult> Index(string sso, string sig)
{
_logger.Info("Discourse proxy signin called");
if (!IsValid(sso, sig))
{
throw new SecurityException("sso sig not valid");
}
var ctx = Request.GetOwinContext();
var idsrvClaimsIdentity = await ctx.Environment.GetIdentityServerFullLoginAsync();
var isAuthenticated = idsrvClaimsIdentity != null;
if (isAuthenticated)
{
_logger.Info("Discourse proxy is logged in to IdSrv, returning user to Discourse");
// User authenticated, getting user, generating sso and redirecting back to Discourse
if (Guid.TryParse(idsrvClaimsIdentity.FindFirst(c => c.Type == "sub").Value, out var id))
{
var user = _userAccountService.GetByID(id);
var redirectUrl = CreateDiscourseRedirectUrl(user, sso);
return new RedirectResult(redirectUrl);
}
}
// Not authenticated, redirecting to make an authenticaiton request against IdentityServer
var urlhelper = Request.Url;
var cbBuilder = new UriBuilder(Request.GetApplicationUrl())
{
Port = urlhelper.Port,
Path = "identity/discourse/callback",
};
var builder = new UriBuilder(Request.GetApplicationUrl())
{
Port = urlhelper.Port,
Path = "identity/connect/authorize",
};
var query = HttpUtility.ParseQueryString(builder.Query);
query["client_id"] = "CLIENT_ID_OF_OUR_PROXY";
query["scope"] = "openid email";
query["response_type"] = "code";
query["redirect_uri"] = cbBuilder.ToString();
query["state"] = sso;
query["nonce"] = Guid.NewGuid().ToString();
builder.Query = query.ToString();
string url = builder.ToString();
_logger.Info("Discourse proxy is not logged in to IdSrv, redirecting to login");
return new RedirectResult(url);
}
/// <summary>
/// Receives authentication result from IdentityServer
/// </summary>
/// <param name="code"></param>
/// <param name="state"></param>
/// <returns></returns>
[Route("identity/discourse/callback")]
[HttpGet]
public async Task<ActionResult> Callback(string code, string state, string error)
{
if (!string.IsNullOrWhiteSpace(error))
{
TempData["error"] = error;
return View();
}
var sso = state;
// normally we would have to use the code to get a token to find out the user
// but as we are running in same pipeline as IdentityServer we can just use the
// OwinContext extensions
var ctx = Request.GetOwinContext();
var idsrvClaimsIdentity = await ctx.Environment.GetIdentityServerFullLoginAsync();
var isAuthenticated = idsrvClaimsIdentity != null;
if (isAuthenticated)
{
_logger.Info("Discourse proxy is logged in to IdSrv, returning user to Discourse");
// User authenticated, getting user, generating sso and redirecting back to Discourse
if (Guid.TryParse(idsrvClaimsIdentity.FindFirst(c => c.Type == "sub").Value, out var id))
{
var user = _userAccountService.GetByID(id);
var redirectUrl = CreateDiscourseRedirectUrl(user, sso);
return new RedirectResult(redirectUrl);
}
}
_logger.Fatal("Discourse proxy cannot resolve logged in user");
TempData["error"] = "Discourse proxy cannot resolve logged in user";
return View();
}
[Route("identity/discourse/logout")]
[HttpGet]
public ActionResult Logout()
{
Request.GetOwinContext().Authentication.SignOut();
return Redirect("/");
}
private string CreateDiscourseRedirectUrl(CustomUser user, string originalEncodedsso)
{
var urlParameters = ParseSSO(originalEncodedsso);
var nonce = urlParameters.Get("nonce");
var returnUrl = urlParameters.Get("return_sso_url");
ValidateKnownUrl(returnUrl);
var userClaims = _userAccountService.GetClaimsFromAccount(user).ToList();
var username = GetUserName(user, userClaims);
var email = userClaims.First(x => x.Type == "email").Value;
var ssoDictionary = new Dictionary<string, string>
{
{"nonce", nonce},
{"email", HttpUtility.UrlEncode(email)},
{"external_id", HttpUtility.UrlEncode(userClaims.First(x => x.Type == "sub" ).Value)},
{"username", HttpUtility.UrlEncode(username)},
{"name", HttpUtility.UrlEncode(userClaims.First(x => x.Type == "name" ).Value)}
};
// at this point could use other claims, for example:
// to determine if user should be Discourse moderator
// to add user to specific Discourse groups
var returnsso = CreateSSOQueryString(ssoDictionary);
var returnssoEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(returnsso));
var returnSig = Hash(_secret, returnssoEncoded);
return $"{returnUrl}?sso={returnssoEncoded}&sig={returnSig}";
}
private string GetUserName(CustomUser user, List<Claim> userclaims)
{
var username = user.Username;
// fixup username for users from our own Azure AD
var issuer = userclaims.FirstOrDefault(x => x.Type == "iss");
if (issuer?.Value == "https://sts.windows.net/Our Azure AD tenant/")
{
//WARNING - magic number hardcoded tenant
var upn = userclaims.FirstOrDefault(x => x.Type == "upn");
username = upn?.Value;
}
else
{
//fixup username for Google login
var urn = userclaims.FirstOrDefault(x => x.Type == "urn:google:profile");
if (urn != null
&& userclaims.FirstOrDefault(x => x.Type == "email") != null)
{
var email = userclaims.FirstOrDefault(x => x.Type == "email").Value;
username = email.Replace("@", "_");
}
}
return username;
}
private List<string> ValidRedirectUris = new List<string>
{
"https://DISCOURSEHOST/session/sso_login"
};
private void ValidateKnownUrl(string returnUrl)
{
if (!ValidRedirectUris.Any(u => u.Equals(returnUrl)))
throw new ApplicationException("Bad Discourse redirect uri");
}
private string Hash(string secret, string encodedPayload)
{
var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(encodedPayload));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private NameValueCollection ParseSSO(string encodedsso)
{
var queryString = Encoding.UTF8.GetString(Convert.FromBase64String(encodedsso));
return HttpUtility.ParseQueryString(queryString);
}
private string CreateSSOQueryString(Dictionary<string, string> dictionary)
{
return string.Join("&", dictionary.Select(x => $"{x.Key}={x.Value}"));
}
private bool IsValid(string encodedPayload, string signature)
{
return Hash(_secret, encodedPayload) == signature;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment