|
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; |
|
} |
|
} |
|
} |