Created
October 2, 2018 18:20
-
-
Save synesthesia/4b3173e1b26aaa66ed40d649f125ca4a to your computer and use it in GitHub Desktop.
Just the source code from https://gist.github.com/synesthesia/5b32cc3f43d414a2a41030571c36381e
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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