diff --git a/App/StackExchange.DataExplorer.Tests/Models/TestQueryExecutions.cs b/App/StackExchange.DataExplorer.Tests/Models/TestQueryExecutions.cs index 39348394..e607a925 100644 --- a/App/StackExchange.DataExplorer.Tests/Models/TestQueryExecutions.cs +++ b/App/StackExchange.DataExplorer.Tests/Models/TestQueryExecutions.cs @@ -15,7 +15,7 @@ public class TestQueryExecutions : BaseTest { public void TestBatchingBatch() { string sql = "print 1 \nGO\nprint 2"; var site = Current.DB.Sites.First(); - var user = User.CreateUser("Fred", "a@a.com", "xyzdsa"); + var user = User.CreateUser("Fred", "a@a.com"); var results = QueryRunner.ExecuteNonCached(new ParsedQuery(sql, null), site, user, null); Assert.AreEqual(0, results.ResultSets.Count()); @@ -27,7 +27,7 @@ public void TestBatchingBatch() { public void TestMultiResultSetsInStatement() { string sql = "select 1 select 2"; var site = Current.DB.Sites.First(); - var user = User.CreateUser("Fred", "a@a.com", "xyzdsa"); + var user = User.CreateUser("Fred", "a@a.com"); var results = QueryRunner.ExecuteNonCached(new ParsedQuery(sql, null), site, user, null); Assert.AreEqual(2, results.ResultSets.Count()); diff --git a/App/StackExchange.DataExplorer.Tests/Models/TestUser.cs b/App/StackExchange.DataExplorer.Tests/Models/TestUser.cs index 3561e35f..729f6401 100644 --- a/App/StackExchange.DataExplorer.Tests/Models/TestUser.cs +++ b/App/StackExchange.DataExplorer.Tests/Models/TestUser.cs @@ -12,32 +12,13 @@ namespace StackExchange.DataExplorer.Tests.Models { [TestClass] public class TestUser : BaseTest { - - [TestMethod] - public void TestUserCreationSetsCreationDate() { - var u = User.CreateUser("Fred", "a@a.com", "xyz"); - Assert.IsNotNull(u.CreationDate); - } - - [TestMethod] - public void TestBasicUserCreation() { - - User.CreateUser("Fred", "a@a.com", "xyz"); - - var u2 = Current.DB.Query("select * from Users where Login = @Login", new {Login = "Fred"}).First(); - Assert.AreEqual("Fred", u2.Login); - - var o = Current.DB.Query("select * from UserOpenIds where OpenIdClaim = @claim", new {claim = "xyz"}).FirstOrDefault(); - Assert.AreEqual("xyz", o.OpenIdClaim); - } - [TestMethod] public void TestNoName() { Current.DB.Execute("delete from Users where Login like 'jon.doe%'"); - var u1 = User.CreateUser("", null, "xyz"); - var u2 = User.CreateUser(null, "", "xyz1"); + var u1 = User.CreateUser("", null); + var u2 = User.CreateUser(null, ""); Assert.AreEqual("jon.doe", u1.Login); // This behaviour is probably not what we want @@ -49,13 +30,13 @@ public void TestNoSpaces() { Current.DB.Execute("delete from Users where Login like 'jon.doe%'"); - var u1 = User.CreateUser("jon doe", null, "xyz"); + var u1 = User.CreateUser("jon doe", null); Assert.AreEqual("jon.doe", u1.Login); } [TestMethod] public void TestWeirdChars() { - var u1 = User.CreateUser("jon&*doe", null, "xyz"); + var u1 = User.CreateUser("jon&*doe", null); Assert.AreEqual(u1.Login, "jondoe"); } } diff --git a/App/StackExchange.DataExplorer/App_Start/BundleConfig.cs b/App/StackExchange.DataExplorer/App_Start/BundleConfig.cs index 670fc423..5359466d 100644 --- a/App/StackExchange.DataExplorer/App_Start/BundleConfig.cs +++ b/App/StackExchange.DataExplorer/App_Start/BundleConfig.cs @@ -61,9 +61,15 @@ private static void RegisterBundles(BundleCollection bundles) .Include("~/Content/homepage.css") .Include("~/Content/topbar.css", new CssRewriteUrlTransform()) .Include("~/Content/header.css", new CssRewriteUrlTransform()) + .Include("~/Content/user.css") .Include("~/Content/jquery.autocomplete.css") ); + bundles.Add(new StyleBundle("~/assets/css/login") + .Include("~/Content/login.css", new CssRewriteUrlTransform()) + .Include("~/Content/login-providers.css", new CssRewriteUrlTransform()) + ); + bundles.Add(new StyleBundle("~/assets/css/query") .Include("~/Content/codemirror/codemirror.css") .Include("~/Content/codemirror/custom.css") diff --git a/App/StackExchange.DataExplorer/Content/header.css b/App/StackExchange.DataExplorer/Content/header.css index 537fb559..8047c7db 100644 --- a/App/StackExchange.DataExplorer/Content/header.css +++ b/App/StackExchange.DataExplorer/Content/header.css @@ -130,7 +130,7 @@ nav.primary .site-selector-popup .ac_results li.ac_over { margin-bottom: 0; } -#tabs { +.nav-tabs { float: right; } @@ -138,7 +138,7 @@ nav.primary .site-selector-popup .ac_results li.ac_over { margin-left: 5px; } -#tabs a, .miniTabs a { +.nav-tabs a, .miniTabs a { background:none repeat scroll 0 0 #fff; border: 1px solid #fff; color: #777; @@ -152,7 +152,7 @@ nav.primary .site-selector-popup .ac_results li.ac_over { text-decoration: none; } -#tabs a:hover, .miniTabs a:hover { +.nav-tabs a:hover, .miniTabs a:hover { background: none repeat scroll 0 0 #FFFFFF; border-color: #CCC #CCC #FFFFFF; border-style: solid; @@ -161,7 +161,7 @@ nav.primary .site-selector-popup .ac_results li.ac_over { margin-top: 9px; } -#tabs a.youarehere, .miniTabs a.youarehere { +.nav-tabs a.youarehere, .miniTabs a.youarehere { background: none repeat scroll 0 0 #FFFFFF; border-color: #CCC #CCC #FFFFFF; border-style: solid; diff --git a/App/StackExchange.DataExplorer/Content/login-providers.css b/App/StackExchange.DataExplorer/Content/login-providers.css new file mode 100644 index 00000000..5ad54312 --- /dev/null +++ b/App/StackExchange.DataExplorer/Content/login-providers.css @@ -0,0 +1,8 @@ +.stackexchange { background-position: -356px 0; } +.google { background-position: -426px 0; } +.yahoo { background-position: 1px 4px; } +.livejournal { background-position: -73px 4px; } +.wordpress { background-position: -38px 4px; } +.blogger { background-position: -108px 4px; } +.verisign { background-position: -458px 4px; } +.aol { background-position: -142px 4px; } \ No newline at end of file diff --git a/App/StackExchange.DataExplorer/Content/user.css b/App/StackExchange.DataExplorer/Content/user.css new file mode 100644 index 00000000..e641d6c6 --- /dev/null +++ b/App/StackExchange.DataExplorer/Content/user.css @@ -0,0 +1,104 @@ +.user-profile .page > .subheader { + height: 40px; +} + +.user-profile .page > .subheader .nav-tabs { + float: left; +} + +.user-profile .page > .subheader .nav-tabs a, +.user-profile .page > .subheader .nav-tabs a.youarehere { + border: 0px; + font-size: 13px; + line-height: 1.3em; + height: auto; + padding: 11px 7px 12px 7px; + margin: 0 20px 0 0; +} + +.user-profile .page > .subheader .nav-tabs a:hover { + color: #0C57A0; + border-bottom: 2px solid #0C57A0; +} + +.user-profile .page > .subheader .nav-tabs a.youarehere { + font-weight: bold; + color: #0C57A0; + border-bottom: 2px solid #0C57A0; +} + +.user-profile #add-login { + float: right; + font-weight: bold; + margin-top: -22px; +} + +.user-profile #user-logins { + list-style-type: none; + margin: 0; +} + +.user-profile .user-login { + margin-top: 14px; + margin-bottom: 20px; + padding-left: 24px; +} + +.user-profile .user-login h2 { + font-size: 16px; + margin-left: -24px; +} + +.user-profile .user-login span.icon { + background-image: url('./img/login-icons.svg'); + background-repeat: no-repeat; + display: inline-block; + width: 16px; + height: 16px; + margin-bottom: -2px; + margin-right: 8px; +} + +.user-profile .user-login .claim-identifier { + display: block; + margin-top: 5px; + color: #CCC; +} + +.sectioned-nav { + float: left; + list-style-type: none; + margin: 0; + width: 220px; +} + +.sectioned-nav li { + padding: 10px; + padding-right: 0; +} + +.sectioned-nav li:hover { + background-color: #D8E8F7; +} + +.sectioned-nav li a { + display: block; + color: #777; + padding: 4px; +} + +.sectioned-nav li:hover a, .sectioned-nav li.youarehere a { + text-decoration: none; + border-right: 3px solid #0C57A0; + color: #0C57A0; +} + +.sectioned-nav li.youarehere a { + font-weight: bold; +} + +.sectioned-content { + margin-left: 220px; + padding-left: 50px; + padding-top: 10px; +} \ No newline at end of file diff --git a/App/StackExchange.DataExplorer/Controllers/AccountController.cs b/App/StackExchange.DataExplorer/Controllers/AccountController.cs index 87011e2a..7cda53b0 100644 --- a/App/StackExchange.DataExplorer/Controllers/AccountController.cs +++ b/App/StackExchange.DataExplorer/Controllers/AccountController.cs @@ -1,4 +1,5 @@ using System; +using System.IdentityModel.Tokens; using System.IO; using System.Linq; using System.Net; @@ -41,7 +42,7 @@ public ActionResult Login(string returnUrl, string message = null) return View("LoginActiveDirectory"); //case AppSettings.AuthenitcationMethod.Default: default: - SetHeader(CurrentUser.IsAnonymous ? "Log in with OpenID" : "Log in below to change your OpenID"); + SetHeader(CurrentUser.IsAnonymous ? "Log in to access your Data Explorer account" : "Add a new login method to your account"); return View("Login"); } } @@ -144,89 +145,11 @@ public ActionResult Authenticate(string returnUrl) var normalizedClaim = Models.User.NormalizeOpenId(response.ClaimedIdentifier.ToString()); var sreg = response.GetExtension(); var isSecure = originalClaim.StartsWith("https://"); + var email = sreg != null && sreg.Email != null && sreg.Email.Length > 2 ? sreg.Email : null; + var displayName = sreg != null ? sreg.Nickname ?? sreg.FullName : null; - var whitelistEmail = sreg != null && sreg.Email != null && sreg.Email.Length > 2 ? sreg.Email : null; - var whiteListResponse = CheckWhitelist(normalizedClaim, whitelistEmail); - if (whiteListResponse != null) - return whiteListResponse; - - User user = null; - var openId = Current.DB.Query("SELECT * FROM UserOpenIds WHERE OpenIdClaim = @normalizedClaim", new { normalizedClaim }).FirstOrDefault(); - - if (!CurrentUser.IsAnonymous) - { - if (openId != null && openId.UserId != CurrentUser.Id) //Does another user have this OpenID - { - //TODO: Need to perform a user merge - SetHeader("Log in below to change your OpenID"); - return LoginError("Another user with this OpenID already exists, merging is not possible at this time."); - } - - var currentOpenIds = Current.DB.Query("select * from UserOpenIds where UserId = @Id", new {CurrentUser.Id}); - - // If a user is merged and then tries to add one of the OpenIDs used for the two original users, - // this update will fail...so don't attempt it if we detect that's the case. Really we should - // work on allowing multiple OpenID logins, but for now I'll settle for not throwing an exception... - if (!currentOpenIds.Any(s => s.OpenIdClaim == normalizedClaim)) - { - Current.DB.UserOpenIds.Update(currentOpenIds.First().Id, new { OpenIdClaim = normalizedClaim }); - } - - user = CurrentUser; - returnUrl = "/users/" + user.Id; - } - else if (openId == null) - { - if (sreg != null && IsVerifiedEmailProvider(normalizedClaim)) - { - // Eh...We can trust the verified email provider, but we can't really trust Users.Email. - // I can't think of a particularly malicious way this could be exploited, but it's likely - // worth reviewing at some point. - user = Current.DB.Query("select * from Users where Email = @Email", new { sreg.Email }).FirstOrDefault(); - - if (user != null) - { - Current.DB.UserOpenIds.Insert(new { UserId = user.Id, OpenIdClaim = normalizedClaim, isSecure }); - } - } - - if (user == null) - { - // create new user - string email = ""; - string login = ""; - if (sreg != null) - { - email = sreg.Email; - login = sreg.Nickname ?? sreg.FullName; - } - user = Models.User.CreateUser(login, email, normalizedClaim); - } - } - else - { - user = Current.DB.Users.Get(openId.UserId); - - if (AppSettings.EnableEnforceSecureOpenId && user.EnforceSecureOpenId && !isSecure && openId.IsSecure) - { - return LoginError("User preferences prohibit insecure (non-https) variants of the provided OpenID identifier"); - } - else if (isSecure && !openId.IsSecure) - { - Current.DB.UserOpenIds.Update(openId.Id, new { IsSecure = true }); - } - } - - IssueFormsTicket(user); - - if (!string.IsNullOrEmpty(returnUrl)) - { - return Redirect(returnUrl); - } - else - { - return RedirectToAction("Index", "Home"); - } + return LoginUser(new UserAuthClaim.Identifier(normalizedClaim, UserAuthClaim.ClaimType.OpenID), email, displayName, + returnUrl, IsVerifiedEmailProvider(originalClaim), isSecure); case AuthenticationStatus.Canceled: return LoginError("Canceled at provider"); case AuthenticationStatus.Failed: @@ -236,18 +159,56 @@ public ActionResult Authenticate(string returnUrl) return new EmptyResult(); } - private ActionResult LoginViaEmail(string email, string displayName, string returnUrl) + private ActionResult LoginUser(UserAuthClaim.Identifier identifier, string email, string displayName, string returnUrl, bool trustEmail, bool isSecure = false, UserAuthClaim.Identifier legacyIdentifier = null) { - var whiteListResponse = CheckWhitelist("", email, trustEmail: true); + var whiteListResponse = CheckWhitelist(identifier.Value, email, trustEmail: trustEmail); + if (whiteListResponse != null) return whiteListResponse; - var user = Current.DB.Query("Select * From Users Where Email = @email", new { email }).FirstOrDefault(); + Tuple identity = Models.User.FindUserIdentityByAuthClaim(email, identifier, CurrentUser.IsAnonymous && trustEmail, legacyIdentifier: legacyIdentifier); + + var user = identity.Item1; + var claim = identity.Item2; + + if (!CurrentUser.IsAnonymous && user != null && user.Id != CurrentUser.Id) + { + //TODO: Need to perform a user merge + SetHeader("Log in below to change your OpenID"); + + return LoginError("Another user with this login already exists, merging is not possible at this time."); + } - // Create the user if not found if (user == null) { - user = Models.User.CreateUser(displayName, email, null); + user = !CurrentUser.IsAnonymous ? CurrentUser : Models.User.CreateUser(displayName, email); + } + + if (claim == null) + { + Current.DB.UserAuthClaims.Insert(new { UserId = user.Id, ClaimIdentifier = identifier.Value, IdentifierType = identifier.Type, IsSecure = isSecure, Display = trustEmail ? email : null }); + } + else if (claim.UserId != user.Id) + { + // This implies there's an orphan claim record somehow, I don't think this should be possible + Current.DB.UserAuthClaims.Update(claim.Id, new { UserId = user.Id }); + } + else + { + // This checking is only relevant to OpenID…which is kind of auth-type specific logic for this method, but meh + if (AppSettings.EnableEnforceSecureOpenId && user.EnforceSecureOpenId && !isSecure && claim.IsSecure) + { + return LoginError("User preferences prohibit insecure (non-https) variants of the provided OpenID identifier"); + } + else if (isSecure && !claim.IsSecure) + { + Current.DB.UserAuthClaims.Update(claim.Id, new { IsSecure = true }); + } + + if (trustEmail && claim.Display != email) + { + Current.DB.UserAuthClaims.Update(claim.Id, new { Display = email }); + } } IssueFormsTicket(user); @@ -256,6 +217,7 @@ private ActionResult LoginViaEmail(string email, string displayName, string retu { return Redirect(returnUrl); } + return RedirectToAction("Index", "Home"); } @@ -340,13 +302,14 @@ private ActionResult OAuthLogin() // return Redirect(redirect); case "https://accounts.google.com/o/oauth2/auth": // Google GetGoogleConfig(out secret, out clientId, out path); + return Redirect(string.Format( - "{0}?client_id={1}&scope=openid+email&redirect_uri={2}&state={3}&response_type=code", - server, - clientId, - (BaseUrl + path).UrlEncode(), - stateJson.UrlEncode() - )); + "{0}?client_id={1}&scope=openid+email&redirect_uri={2}&state={3}&response_type=code&openid.realm={2}", + server, + clientId, + (BaseUrl + path).UrlEncode(), + stateJson.UrlEncode() + )); } return LoginError("Unsupported OAuth version or server"); @@ -395,9 +358,10 @@ public ActionResult GoogleCallback(string code, string state, string error) var responseStr = Encoding.UTF8.GetString(response); authResponse = JsonConvert.DeserializeObject(responseStr); } - if (authResponse != null) + + if (authResponse != null && !authResponse.error.HasValue()) { - var loginResponse = FetchFromGoogle(authResponse.access_token); + var loginResponse = FetchFromGoogle(authResponse.access_token, authResponse.id_token); if (loginResponse != null) return loginResponse; } } @@ -430,8 +394,11 @@ public ActionResult GoogleCallback(string code, string state, string error) return LoginError("Google authentication failed"); } - private ActionResult FetchFromGoogle(string accessToken) + private ActionResult FetchFromGoogle(string accessToken, string idToken) { + // We're not bothering to validate the id token because we just got it back directly from Google over HTTPS + var legacyIdentifier = (string)new JwtSecurityToken(idToken).Payload["openid_id"]; + string result = null; Exception lastException = null; for (var retry = 0; retry < GoogleAuthRetryAttempts; retry++) @@ -479,7 +446,14 @@ private ActionResult FetchFromGoogle(string accessToken) if (person.email == null) return LoginError("Error fetching email from Google"); - return LoginViaEmail(person.email, person.name, "/"); + return LoginUser( + new UserAuthClaim.Identifier(person.id, UserAuthClaim.ClaimType.Google), + person.email, + person.name, + "/", + person.verified_email, + legacyIdentifier: legacyIdentifier.HasValue() ? new UserAuthClaim.Identifier(Models.User.NormalizeOpenId(legacyIdentifier), UserAuthClaim.ClaimType.OpenID) : null + ); } catch (Exception e) { @@ -546,6 +520,7 @@ public class OAuthLoginState public class GoogleAuthResponse { public string access_token { get; set; } + public string id_token { get; set; } public string error { get; set; } public string error_description { get; set; } } diff --git a/App/StackExchange.DataExplorer/Controllers/AdminController.cs b/App/StackExchange.DataExplorer/Controllers/AdminController.cs index cb9f69e3..e1827483 100644 --- a/App/StackExchange.DataExplorer/Controllers/AdminController.cs +++ b/App/StackExchange.DataExplorer/Controllers/AdminController.cs @@ -137,9 +137,9 @@ where grp.Count() > 1 } else { - var openids = Current.DB.Query("select * from UserOpenIds").ToList(); + var openids = Current.DB.UserAuthClaims.All(); dupeUserIds = (from openid in openids - group openid by Models.User.NormalizeOpenId(openid.OpenIdClaim) + group openid by Models.User.NormalizeOpenId(openid.ClaimIdentifier) into grp where grp.Count() > 1 select new Tuple>(grp.Key, grp.Select(id => id.UserId).OrderBy(id => id))).ToList(); @@ -169,12 +169,12 @@ where grp.Count() > 1 [StackRoute("admin/normalize-openids")] public ActionResult NormalizeOpenIds() { - foreach (var openId in Current.DB.UserOpenIds.All()) + foreach (var openId in Current.DB.UserAuthClaims.All()) { - var cleanClaim = Models.User.NormalizeOpenId(openId.OpenIdClaim); - if (cleanClaim != openId.OpenIdClaim) + var cleanClaim = Models.User.NormalizeOpenId(openId.ClaimIdentifier); + if (cleanClaim != openId.ClaimIdentifier) { - Current.DB.UserOpenIds.Update(openId.Id, new { OpenIdClaim = cleanClaim }); + Current.DB.UserAuthClaims.Update(openId.Id, new { ClaimIdentifier = cleanClaim }); } } return TextPlain("Done."); @@ -239,12 +239,12 @@ public ActionResult MergeSubmit(int masterId, int mergeId) public ActionResult FindDuplicateUserOpenIds() { - var sql = "select * from UserOpenIds where UserId in (select UserId from UserOpenId having count(*) > 0)"; + var sql = "select * from UserAuthClaims where UserId in (select UserId from UserAuthClaims having count(*) > 0)"; - var dupes = (from uoi in Current.DB.Query(sql) + var dupes = (from uoi in Current.DB.Query(sql) group uoi by uoi.UserId into grp - select new Tuple>(grp.Key, grp.Select(g=>g))).ToList(); + select new Tuple>(grp.Key, grp.Select(g=>g))).ToList(); SetHeader("Possible Duplicate User OpenId records"); return View(dupes); } @@ -253,7 +253,7 @@ into grp [StackRoute("admin/useropenid/remove/{id:int}", HttpVerbs.Post)] public ActionResult RemoveUserOpenIdEntry(int id) { - Current.DB.UserOpenIds.Delete(id); + Current.DB.UserAuthClaims.Delete(id); return Json("ok"); } diff --git a/App/StackExchange.DataExplorer/Controllers/UserController.cs b/App/StackExchange.DataExplorer/Controllers/UserController.cs index 83d43a69..0dd1357c 100644 --- a/App/StackExchange.DataExplorer/Controllers/UserController.cs +++ b/App/StackExchange.DataExplorer/Controllers/UserController.cs @@ -122,7 +122,12 @@ FROM Users /**join**/ /**where**/ [StackRoute(@"users/edit/{id:\d+}", RoutePriority.High)] public ActionResult Edit(int id, User updatedUser) { - User user = Current.DB.Users.Get(id); + User user = CanViewPrivateInfoFor(id) ? GetUser(id) : null; + + if (user == null) + { + return PageNotFound(); + } if (updatedUser.DOB < DateTime.UtcNow.AddYears(-100) || updatedUser.DOB > DateTime.UtcNow.AddYears(-6)) { @@ -175,23 +180,32 @@ public ActionResult Edit(int id, User updatedUser) [StackRoute(@"users/edit/{id:\d+}", RoutePriority.High)] public ActionResult Edit(int id) { - User user = Current.DB.Users.Get(id); + User user = CanViewPrivateInfoFor(id) ? GetUser(id) : null; + if (user == null) { return PageNotFound(); } - if (user.Id == CurrentUser.Id || CurrentUser.IsAdmin) - { - SetHeader(user.Login + " - Edit"); - SelectMenuItem("Users"); + SetProfileMetadata(user, true); - return View(user); - } - else + return View(user); + } + + [HttpGet] + [StackRoute(@"users/logins/{id:\d+}", RoutePriority.High)] + public ActionResult Logins(int id) + { + User user = AppSettings.AuthMethod == AppSettings.AuthenitcationMethod.Default && CanViewPrivateInfoFor(id) ? GetUser(id) : null; + + if (user == null) { - return Redirect("/"); + return PageNotFound(); } + + SetProfileMetadata(user, true, "logins"); + + return View(user); } [HttpPost] @@ -202,9 +216,9 @@ public ActionResult SavePreference(int id, string preference, string value) return ContentError("Invalid preference"); } - User user = Current.DB.Users.Get(id); + User user = CanViewPrivateInfoFor(id) ? GetUser(id) : null; - if (user == null || (user.Id != CurrentUser.Id && !CurrentUser.IsAdmin)) + if (user == null) { return ContentError("Invalid action"); } @@ -220,7 +234,7 @@ public ActionResult SavePreference(int id, string preference, string value) [StackRoute(@"users/{id:INT}/{name?}")] public ActionResult Show(int id, string name, string order_by, int? page) { - User user = !Current.User.IsAnonymous && Current.User.Id == id ? Current.User : Current.DB.Users.Get(id); + User user = GetUser(id); if (user == null) { @@ -235,8 +249,7 @@ public ActionResult Show(int id, string name, string order_by, int? page) DataExplorerDatabase db = Current.DB; - SetHeader(user.Login); - SelectMenuItem("Users"); + SetProfileMetadata(user, false); var profileTabs = new SubHeader { @@ -371,5 +384,66 @@ Votes v ON return View(user); } + + private bool CanViewPrivateInfoFor(int id) + { + return !CurrentUser.IsAnonymous && (CurrentUser.Id == id || CurrentUser.IsAdmin); + } + + private User GetUser(int id) + { + return CurrentUser.Id == id ? CurrentUser : Current.DB.Users.Get(id); + } + + private void SetProfileMetadata(User user, bool isEditing, string currentEditPage = null) + { + var subheaders = new List + { + new SubHeaderViewData + { + Description = "Profile", + Href = "/users/" + user.ProfilePath, + Default = true + } + }; + + if (CanViewPrivateInfoFor(user.Id)) + { + subheaders.Add(new SubHeaderViewData + { + Name = "edit", + Description = "Edit Profile & Logins", + Href = "/users/edit/" + user.Id + }); + } + + ViewData["BodyClass"] = "user-profile"; + SelectMenuItem("Users"); + SetHeader(null, isEditing ? "edit" : null, subheaders.ToArray()); + + if (isEditing) + { + ViewData["ProfileNav"] = new SubHeader + { + Selected = currentEditPage, + Items = new List + { + new SubHeaderViewData + { + Name = "profile", + Description = "Edit Profile", + Href = "/users/edit/" + user.Id, + Default = true + }, + new SubHeaderViewData + { + Name = "logins", + Description = "My Logins", + Href = "/users/logins/" + user.Id + } + } + }; + } + } } } \ No newline at end of file diff --git a/App/StackExchange.DataExplorer/Models/User.cs b/App/StackExchange.DataExplorer/Models/User.cs index 317a1082..b16a341e 100644 --- a/App/StackExchange.DataExplorer/Models/User.cs +++ b/App/StackExchange.DataExplorer/Models/User.cs @@ -25,13 +25,13 @@ public partial class User public string PreferencesRaw { get; set; } public string ADLogin { get; set; } - List _userOpenIds; - public List UserOpenIds + List _userAuthClaims; + public List UserAuthClaims { get { - _userOpenIds = _userOpenIds ?? Current.DB.Query("select * from UserOpenIds where UserId = @Id", new { Id }).ToList(); - return _userOpenIds; + _userAuthClaims = _userAuthClaims ?? Current.DB.Query("select * from UserAuthClaims where UserId = @Id", new { Id }).ToList(); + return _userAuthClaims; } } @@ -105,6 +105,41 @@ public static User CreateUser(string accountLogin) return u; } + public static Tuple FindUserIdentityByAuthClaim(string email, UserAuthClaim.Identifier identifier, bool useEmailFallback = true, UserAuthClaim.Identifier legacyIdentifier = null) + { + var claim = Current.DB.Query( + "SELECT * FROM UserAuthClaims WHERE ClaimIdentifier = @identifier AND IdentifierType = @type", + new { identifier = identifier.Value, type = identifier.Type } + ).FirstOrDefault(); + + if (claim == null && legacyIdentifier != null) + { + claim = Current.DB.Query( + "SELECT * FROM UserAuthClaims WHERE ClaimIdentifier = @identifier AND IdentifierType = @type", + new { identifier = legacyIdentifier.Value, type = legacyIdentifier.Type } + ).FirstOrDefault(); + + if (claim != null) + { + // Update the legacy auth claim (only Google OpenID -> email right now) + Current.DB.UserAuthClaims.Update(claim.Id, new { ClaimIdentifier = identifier.Value, IdentifierType = identifier.Type, IsSecure = false, Display = email }); + } + } + + User user = null; + + if (claim != null) + { + user = Current.DB.Users.Get(claim.UserId); + } + else if (useEmailFallback && email.HasValue()) + { + user = Current.DB.Query("SELECT * FROM Users WHERE Email = @email", new { email }).FirstOrDefault(); + } + + return Tuple.Create(user, claim); + } + public void SetAdmin(bool isAdmin) { Current.DB.Execute("Update Users Set IsAdmin = @isAdmin Where Id = @Id", new {Id, isAdmin}); @@ -115,7 +150,7 @@ public static User GetByADLogin(string accountLogin) return Current.DB.Query("Select * From Users Where ADLogin = @accountLogin", new {accountLogin}).SingleOrDefault(); } - public static User CreateUser(string login, string email, string openIdClaim) + public static User CreateUser(string login, string email) { var u = new User(); u.CreationDate = DateTime.UtcNow; @@ -155,8 +190,7 @@ public static User CreateUser(string login, string email, string openIdClaim) } u.Id = Current.DB.Users.Insert(new { u.Email, u.Login, u.CreationDate }).Value; - if (openIdClaim != null) - Current.DB.UserOpenIds.Insert(new {OpenIdClaim = openIdClaim, UserId = u.Id}); + return u; } @@ -242,7 +276,7 @@ from RevisionExecutions r // User Open Ids { - var rempped = db.Execute("update UserOpenIds set UserId = @masterId where UserId = @mergeId", new { mergeId, masterId }); + var rempped = db.Execute("update UserAuthClaims set UserId = @masterId where UserId = @mergeId", new { mergeId, masterId }); log.AppendLine(string.Format("Remapped {0} user open ids", rempped)); } diff --git a/App/StackExchange.DataExplorer/Models/UserAuthClaim.cs b/App/StackExchange.DataExplorer/Models/UserAuthClaim.cs new file mode 100644 index 00000000..1fbf6d4e --- /dev/null +++ b/App/StackExchange.DataExplorer/Models/UserAuthClaim.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace StackExchange.DataExplorer.Models +{ + public class UserAuthClaim + { + private static readonly Regex subdomainUsernameRegex = new Regex("https?://(?[^.]+)\\..*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex pathUsernameRegex = new Regex("https?://[^/]+/(?[^/?]+)(?:/|\\?).*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Dictionary knownProviders = new Dictionary + { + { "openid.stackexchange.com", new AuthProvider("Stack Exchange") }, + { "me.yahoo.com", new AuthProvider("Yahoo") }, + { ".livejournal.com", new AuthProvider("LiveJournal", usernameRegex: subdomainUsernameRegex) }, + { ".wordpress.com", new AuthProvider("Wordpress", usernameRegex: subdomainUsernameRegex) }, + { ".blogspot.com", new AuthProvider("Blogger", usernameRegex: subdomainUsernameRegex) }, + { ".pip.verisignlabs.com", new AuthProvider("Verisign", usernameRegex: subdomainUsernameRegex) }, + { "openid.aol.com", new AuthProvider("AOL", usernameRegex: pathUsernameRegex) }, + { "www.google.com", new AuthProvider("Google") } + }; + + public enum ClaimType + { + OpenID = 1, + Google = 2 + } + + public class Identifier + { + public readonly ClaimType Type; + public readonly string Value; + + public Identifier(String value, ClaimType type) + { + Value = value; + Type = type; + } + } + + public class AuthProvider + { + public string Name { get; private set; } + public string IconClass { get; private set; } + + private readonly Regex usernameRegex; + + public AuthProvider(string name, bool hasIcon = true, Regex usernameRegex = null) + { + Name = name; + + if (hasIcon) + IconClass = name.ToLower().Replace(" ", ""); + + this.usernameRegex = usernameRegex; + } + + public string GetUsername(string identifier) + { + if (usernameRegex == null) + return identifier; + + var username = usernameRegex.Match(identifier).Groups["username"]; + + return username != null ? username.Value : identifier; + } + } + + private string display; + private AuthProvider provider; + + public int Id { get; set; } + public int UserId { get; set; } + public string ClaimIdentifier { get; set; } + public bool IsSecure { get; set; } + public ClaimType IdentifierType { get; set; } + public string Display + { + get { return display ?? Provider.GetUsername(ClaimIdentifier); } + set { display = value; } + } + + public AuthProvider Provider { + get + { + if (provider == null) + { + switch (IdentifierType) + { + case ClaimType.Google: + provider = knownProviders["www.google.com"]; + break; + // case ClaimType.OpenID + default: + var uri = new Uri(ClaimIdentifier); + var host = uri.Host; + var subdomainIndex = host.IndexOf('.'); + + if (!knownProviders.ContainsKey(host) && subdomainIndex != -1 && subdomainIndex != host.LastIndexOf('.')) + { + host = host.Substring(subdomainIndex); + } + + provider = knownProviders.ContainsKey(host) ? knownProviders[host] : new AuthProvider(uri.Host, hasIcon: false); + break; + } + } + + return provider; + } + } + } +} \ No newline at end of file diff --git a/App/StackExchange.DataExplorer/Models/UserOpenId.cs b/App/StackExchange.DataExplorer/Models/UserOpenId.cs deleted file mode 100644 index 5a711529..00000000 --- a/App/StackExchange.DataExplorer/Models/UserOpenId.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; - -namespace StackExchange.DataExplorer.Models -{ - public class UserOpenId - { - public int Id { get; set; } - public int UserId { get; set; } - public string OpenIdClaim { get; set; } - public bool IsSecure { get; set; } - } -} \ No newline at end of file diff --git a/App/StackExchange.DataExplorer/Models/_Database.Specific.cs b/App/StackExchange.DataExplorer/Models/_Database.Specific.cs index 02516fd2..e1a4e6ee 100644 --- a/App/StackExchange.DataExplorer/Models/_Database.Specific.cs +++ b/App/StackExchange.DataExplorer/Models/_Database.Specific.cs @@ -10,7 +10,7 @@ public class DataExplorerDatabase : Dapper.Database public Table Sites { get; private set; } public Table Users { get; private set; } public Table OpenIdWhiteList { get; private set; } - public Table UserOpenIds { get; private set; } + public Table UserAuthClaims { get; private set; } public Table Votes { get; private set; } public Table BlackList { get; private set; } public Table QuerySets { get; private set; } diff --git a/App/StackExchange.DataExplorer/StackExchange.DataExplorer.csproj b/App/StackExchange.DataExplorer/StackExchange.DataExplorer.csproj index d7747170..cc60d608 100644 --- a/App/StackExchange.DataExplorer/StackExchange.DataExplorer.csproj +++ b/App/StackExchange.DataExplorer/StackExchange.DataExplorer.csproj @@ -118,6 +118,11 @@ 3.5 + + + ..\packages\System.IdentityModel.Tokens.Jwt.4.0.2.206221351\lib\net45\System.IdentityModel.Tokens.Jwt.dll + True + False @@ -261,7 +266,7 @@ User.cs - + @@ -283,6 +288,7 @@ + login.less @@ -365,6 +371,7 @@ + @@ -480,6 +487,8 @@ + + 10.0 diff --git a/App/StackExchange.DataExplorer/Views/Account/LogIn.cshtml b/App/StackExchange.DataExplorer/Views/Account/LogIn.cshtml index 61dc6f37..5d05a32f 100644 --- a/App/StackExchange.DataExplorer/Views/Account/LogIn.cshtml +++ b/App/StackExchange.DataExplorer/Views/Account/LogIn.cshtml @@ -1,7 +1,8 @@ @using StackExchange.DataExplorer @using StackExchange.DataExplorer.Helpers +@using System.Web.Optimization; @{this.SetPageTitle("Log In or Register - Stack Exchange Data Explorer");} - +@Styles.Render("~/assets/css/login")
@if (ViewData["Message"] != null) { diff --git a/App/StackExchange.DataExplorer/Views/Shared/Master.cshtml b/App/StackExchange.DataExplorer/Views/Shared/Master.cshtml index cb27e28a..dc63df57 100644 --- a/App/StackExchange.DataExplorer/Views/Shared/Master.cshtml +++ b/App/StackExchange.DataExplorer/Views/Shared/Master.cshtml @@ -32,7 +32,7 @@ }); - +
diff --git a/App/StackExchange.DataExplorer/Views/Shared/SubHeader.cshtml b/App/StackExchange.DataExplorer/Views/Shared/SubHeader.cshtml index 1ab054dd..fcba7810 100644 --- a/App/StackExchange.DataExplorer/Views/Shared/SubHeader.cshtml +++ b/App/StackExchange.DataExplorer/Views/Shared/SubHeader.cshtml @@ -9,7 +9,7 @@ } @if (Model.Items != null) { -
+