Add password hashing, user settings with 2FA, and project image lightbox.

Hardens auth with PBKDF2, lockouts, local QR setup, and safer embeds while moving account security under the username menu.
This commit is contained in:
Atakan Doğan Özban
2026-07-17 18:49:34 +02:00
parent 8ec6e3fd50
commit e97a71e5b2
18 changed files with 1253 additions and 62 deletions
+199 -3
View File
@@ -13,7 +13,7 @@ namespace atakanozbancom.Controllers
private readonly Context db = new Context();
[Authorize]
public ActionResult Index()
public ActionResult Index() // admin dashboard landing page
{
ViewBag.MyProjectsCount = db.MyProjects.Count();
ViewBag.AffiliateLinksCount = db.AffiliateLinks.Count();
@@ -22,7 +22,7 @@ namespace atakanozbancom.Controllers
}
[Authorize]
public ActionResult MyProjects()
public ActionResult MyProjects() // shows what we have
{
var value = db.MyProjects.ToList();
return View(value);
@@ -53,6 +53,7 @@ namespace atakanozbancom.Controllers
DescTR = tr != null ? tr.description : ""
};
// NEW:
vm.medias = db.projectmedia
.Where(m => m.project_id == p.id)
.OrderBy(m => m.sort_order)
@@ -183,11 +184,19 @@ namespace atakanozbancom.Controllers
return RedirectToAction("myprojectsget", new { id = projectId });
}
var trimmed = iframeUrl.Trim();
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var embedUri)
|| (embedUri.Scheme != Uri.UriSchemeHttps && embedUri.Scheme != Uri.UriSchemeHttp))
{
TempData["ok"] = "Iframe URL must be a valid http(s) address.";
return RedirectToAction("myprojectsget", new { id = projectId });
}
var row = new projectmedia
{
project_id = projectId,
media_type = MediaType.Iframe,
url = iframeUrl.Trim(),
url = trimmed,
sort_order = sortOrder
};
@@ -295,6 +304,9 @@ namespace atakanozbancom.Controllers
return RedirectToAction("myprojectsget", new { id = projectId });
}
// ================= AFFILIATE LINKS =================
// .svg intentionally excluded: SVGs can embed <script> and execute when opened directly.
private static readonly string[] AllowedLogoExtensions = { ".jpg", ".jpeg", ".png", ".webp", ".gif" };
private const string AffiliateLogosPrefix = "/Content/uploads/affiliate-logos/";
@@ -328,6 +340,12 @@ namespace atakanozbancom.Controllers
if (!ModelState.IsValid)
return View(vm);
if (!IsSafeHttpUrl(vm.url))
{
ModelState.AddModelError("url", "URL must be a valid http(s) address.");
return View(vm);
}
var logoUrl = SaveAffiliateLogo(vm.logoFile);
if (logoUrl == null)
{
@@ -402,6 +420,13 @@ namespace atakanozbancom.Controllers
return View(vm);
}
if (!IsSafeHttpUrl(vm.url))
{
ModelState.AddModelError("url", "URL must be a valid http(s) address.");
vm.existingImage = a.image;
return View(vm);
}
if (vm.logoFile != null && vm.logoFile.ContentLength > 0)
{
var logoUrl = SaveAffiliateLogo(vm.logoFile);
@@ -486,9 +511,12 @@ namespace atakanozbancom.Controllers
}
catch
{
// silently ignore, matches existing media-delete behavior
}
}
// ================= SOCIAL ICONS (footer) =================
[Authorize]
public ActionResult SocialIcons()
{
@@ -514,6 +542,12 @@ namespace atakanozbancom.Controllers
if (!ModelState.IsValid)
return View(vm);
if (!IsSafeHttpUrl(vm.url))
{
ModelState.AddModelError("url", "URL must be a valid http(s) address.");
return View(vm);
}
var icon = new socialicon
{
icon = vm.icon,
@@ -557,6 +591,12 @@ namespace atakanozbancom.Controllers
if (!ModelState.IsValid)
return View(vm);
if (!IsSafeHttpUrl(vm.url))
{
ModelState.AddModelError("url", "URL must be a valid http(s) address.");
return View(vm);
}
s.icon = vm.icon;
s.url = vm.url;
s.sort_order = vm.sort_order;
@@ -580,6 +620,8 @@ namespace atakanozbancom.Controllers
return RedirectToAction("SocialIcons");
}
// ================= WALLPAPER (site background) =================
private static readonly string[] AllowedWallpaperExtensions = { ".jpg", ".jpeg", ".png", ".webp", ".gif" };
private const string WallpaperPrefix = "/Content/uploads/wallpaper/";
private const string DefaultWallpaperUrl = "/web_atakanozbancom/assets/img/wp.jpg";
@@ -672,5 +714,159 @@ namespace atakanozbancom.Controllers
}
}
[Authorize]
public ActionResult UserSettings()
{
var user = GetCurrentAdmin();
if (user == null)
return RedirectToAction("Index", "login");
ViewBag.TwoFactorEnabled = user.two_factor_enabled;
ViewBag.Username = user.username;
if (!user.two_factor_enabled)
{
var secret = Session["Pending2FaSetupSecret"] as string;
if (string.IsNullOrWhiteSpace(secret))
{
secret = TotpHelper.GenerateSecret();
Session["Pending2FaSetupSecret"] = secret;
}
ViewBag.SetupSecret = secret;
ViewBag.OtpAuthUri = TotpHelper.BuildOtpAuthUri("atakanozban.com", user.username, secret);
}
return View();
}
[Authorize]
public ActionResult Security()
{
return RedirectToAction("UserSettings");
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult ChangePassword(string currentPassword, string newPassword, string confirmPassword)
{
var user = GetCurrentAdmin();
if (user == null)
return RedirectToAction("Index", "login");
if (string.IsNullOrWhiteSpace(currentPassword)
|| string.IsNullOrWhiteSpace(newPassword)
|| string.IsNullOrWhiteSpace(confirmPassword))
{
TempData["pwdError"] = "All password fields are required.";
return RedirectToAction("UserSettings");
}
if (!PasswordHasher.Verify(currentPassword, user.password))
{
TempData["pwdError"] = "Current password is incorrect.";
return RedirectToAction("UserSettings");
}
if (newPassword.Length < 8)
{
TempData["pwdError"] = "New password must be at least 8 characters.";
return RedirectToAction("UserSettings");
}
if (!string.Equals(newPassword, confirmPassword, StringComparison.Ordinal))
{
TempData["pwdError"] = "New password and confirmation do not match.";
return RedirectToAction("UserSettings");
}
user.password = PasswordHasher.Hash(newPassword);
db.SaveChanges();
TempData["pwdOk"] = "Password updated successfully.";
return RedirectToAction("UserSettings");
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult EnableTwoFactor(string code)
{
var user = GetCurrentAdmin();
if (user == null)
return RedirectToAction("Index", "login");
var secret = Session["Pending2FaSetupSecret"] as string;
if (string.IsNullOrWhiteSpace(secret))
{
TempData["tfaError"] = "Setup expired. Please try again.";
return RedirectToAction("UserSettings");
}
if (!TotpHelper.VerifyCode(secret, code))
{
TempData["tfaError"] = "Invalid code. Scan the QR again and enter a fresh code.";
return RedirectToAction("UserSettings");
}
user.two_factor_secret = SecretProtector.Protect(secret);
user.two_factor_enabled = true;
db.SaveChanges();
Session.Remove("Pending2FaSetupSecret");
TempData["tfaOk"] = "Two-factor authentication is now enabled.";
return RedirectToAction("UserSettings");
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult DisableTwoFactor(string code)
{
var user = GetCurrentAdmin();
if (user == null)
return RedirectToAction("Index", "login");
if (!user.two_factor_enabled || string.IsNullOrWhiteSpace(user.two_factor_secret))
{
TempData["tfaError"] = "Two-factor authentication is not enabled.";
return RedirectToAction("UserSettings");
}
var secret = SecretProtector.Unprotect(user.two_factor_secret) ?? user.two_factor_secret;
if (!TotpHelper.VerifyCode(secret, code))
{
TempData["tfaError"] = "Invalid code. 2FA was not disabled.";
return RedirectToAction("UserSettings");
}
user.two_factor_enabled = false;
user.two_factor_secret = null;
db.SaveChanges();
Session.Remove("Pending2FaSetupSecret");
TempData["tfaOk"] = "Two-factor authentication has been disabled.";
return RedirectToAction("UserSettings");
}
private admin GetCurrentAdmin()
{
var username = User?.Identity?.Name;
if (string.IsNullOrWhiteSpace(username))
return null;
return db.admins.FirstOrDefault(x => x.username == username);
}
private static bool IsSafeHttpUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
return Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
}
}
}
+169 -14
View File
@@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Security;
using atakanozbancom.Models.classes;
@@ -10,33 +8,190 @@ namespace atakanozbancom.Controllers
{
public class loginController : Controller
{
// GET: Login
Context c = new Context();
private readonly Context c = new Context();
private const int MaxPasswordFailures = 8;
private const int MaxTotpFailures = 5;
private const int LockMinutes = 15;
public ActionResult Index()
{
return View();
}
[HttpPost]
public ActionResult Index(admin ad)
[ValidateAntiForgeryToken]
public ActionResult Index(admin ad)
{
var bilgiler = c.admins.FirstOrDefault(x => x.username == ad.username && x.password == ad.password);
if (bilgiler != null)
{
FormsAuthentication.SetAuthCookie(bilgiler.username, false);
Session["username"] = bilgiler.username.ToString();
return RedirectToAction("Index", "admin");
}
else
if (ad == null || string.IsNullOrWhiteSpace(ad.username) || string.IsNullOrWhiteSpace(ad.password))
{
ViewBag.Error = "Username and password are required.";
return View();
}
var user = c.admins.FirstOrDefault(x => x.username == ad.username);
if (user == null)
{
ViewBag.Error = "Invalid username or password.";
return View();
}
if (user.login_lock_until.HasValue && user.login_lock_until.Value > DateTime.UtcNow)
{
ViewBag.Error = "Account temporarily locked. Try again later.";
return View();
}
if (!PasswordHasher.Verify(ad.password, user.password))
{
user.login_failed_count++;
if (user.login_failed_count >= MaxPasswordFailures)
{
user.login_lock_until = DateTime.UtcNow.AddMinutes(LockMinutes);
user.login_failed_count = 0;
}
c.SaveChanges();
ViewBag.Error = "Invalid username or password.";
return View();
}
user.login_failed_count = 0;
user.login_lock_until = null;
if (!PasswordHasher.IsHashed(user.password))
{
user.password = PasswordHasher.Hash(ad.password);
}
c.SaveChanges();
ClearPending2Fa();
if (user.two_factor_enabled && !string.IsNullOrWhiteSpace(user.two_factor_secret))
{
if (user.totp_lock_until.HasValue && user.totp_lock_until.Value > DateTime.UtcNow)
{
ViewBag.Error = "Too many failed 2FA attempts. Try again later.";
return View();
}
Session["Pending2FaUserId"] = user.id;
Session["Pending2FaUsername"] = user.username;
Session["Pending2FaExpiresUtc"] = DateTime.UtcNow.AddMinutes(10);
return RedirectToAction("Verify");
}
CompleteLogin(user.username);
return RedirectToAction("Index", "admin");
}
public ActionResult Verify()
{
if (!HasValidPending2Fa())
{
ClearPending2Fa();
return RedirectToAction("Index");
}
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Verify(string code)
{
if (!HasValidPending2Fa())
{
ClearPending2Fa();
return RedirectToAction("Index");
}
var pendingId = Convert.ToInt32(Session["Pending2FaUserId"]);
var pendingUsername = Session["Pending2FaUsername"].ToString();
var user = c.admins.FirstOrDefault(x => x.id == pendingId && x.username == pendingUsername);
if (user == null || !user.two_factor_enabled || string.IsNullOrWhiteSpace(user.two_factor_secret))
{
ClearPending2Fa();
return RedirectToAction("Index");
}
if (user.totp_lock_until.HasValue && user.totp_lock_until.Value > DateTime.UtcNow)
{
ViewBag.Error = "Too many failed attempts. Try again later.";
return View();
}
var secret = ResolveTotpSecret(user);
if (string.IsNullOrWhiteSpace(secret) || !TotpHelper.VerifyCode(secret, code))
{
user.totp_failed_count++;
if (user.totp_failed_count >= MaxTotpFailures)
{
user.totp_lock_until = DateTime.UtcNow.AddMinutes(LockMinutes);
user.totp_failed_count = 0;
ViewBag.Error = "Too many failed attempts. Try again later.";
}
else
{
ViewBag.Error = "Invalid authentication code.";
}
c.SaveChanges();
return View();
}
user.totp_failed_count = 0;
user.totp_lock_until = null;
c.SaveChanges();
ClearPending2Fa();
CompleteLogin(user.username);
return RedirectToAction("Index", "admin");
}
public ActionResult logout()
{
ClearPending2Fa();
Session.Remove("username");
Session.Abandon();
FormsAuthentication.SignOut();
return RedirectToAction("index", "login");
}
private void CompleteLogin(string username)
{
FormsAuthentication.SetAuthCookie(username, false);
Session["username"] = username;
}
private bool HasValidPending2Fa()
{
if (Session["Pending2FaUserId"] == null || Session["Pending2FaUsername"] == null)
return false;
var expires = Session["Pending2FaExpiresUtc"] as DateTime?;
if (expires == null || expires.Value < DateTime.UtcNow)
return false;
return true;
}
private static string ResolveTotpSecret(admin user)
{
if (string.IsNullOrWhiteSpace(user.two_factor_secret))
return null;
var unprotected = SecretProtector.Unprotect(user.two_factor_secret);
if (!string.IsNullOrWhiteSpace(unprotected))
return unprotected;
return user.two_factor_secret;
}
private void ClearPending2Fa()
{
Session.Remove("Pending2FaUserId");
Session.Remove("Pending2FaUsername");
Session.Remove("Pending2FaSetupSecret");
Session.Remove("Pending2FaExpiresUtc");
}
}
}
}