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
+1
View File
@@ -108,6 +108,7 @@
<strong>@(User?.Identity?.Name ?? "Admin")</strong>
</a>
<ul class="dropdown-menu dropdown-menu-dark text-small shadow">
<li><a class="dropdown-item" href="/admin/usersettings"><i class="fa-solid fa-user-gear me-2"></i>User Settings</a></li>
<li><a class="dropdown-item" href="/" target="_blank"><i class="fa-solid fa-arrow-up-right-from-square me-2"></i>Go to site</a></li>
<li><hr class="dropdown-divider" /></li>
<li><a class="dropdown-item" href="/login/logout"><i class="fa-solid fa-right-from-bracket me-2"></i>Sign out</a></li>
+5
View File
@@ -0,0 +1,5 @@
@{
// Kept for old bookmarks; controller redirects to UserSettings.
Layout = null;
Response.Redirect(Url.Action("UserSettings", "admin"));
}
+116
View File
@@ -0,0 +1,116 @@
@{
ViewBag.Title = "User Settings";
Layout = "~/Views/Shared/_AdminLayout.cshtml";
var enabled = ViewBag.TwoFactorEnabled == true;
}
<h2 class="text-white mt-3 mb-3">User Settings</h2>
<p class="text-white-50">Account: <strong class="text-white">@ViewBag.Username</strong></p>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="bg-dark border border-secondary rounded p-4 text-white h-100">
<h4 class="mb-3">Change password</h4>
@if (TempData["pwdOk"] != null)
{
<div class="alert alert-success py-2">@TempData["pwdOk"]</div>
}
@if (TempData["pwdError"] != null)
{
<div class="alert alert-danger py-2">@TempData["pwdError"]</div>
}
@using (Html.BeginForm("ChangePassword", "admin", FormMethod.Post))
{
@Html.AntiForgeryToken()
<div class="mb-3">
<label class="form-label">Current password</label>
<input type="password" name="currentPassword" class="form-control bg-dark text-white" autocomplete="current-password" required />
</div>
<div class="mb-3">
<label class="form-label">New password</label>
<input type="password" name="newPassword" class="form-control bg-dark text-white" autocomplete="new-password" minlength="8" required />
<small class="text-muted">At least 8 characters.</small>
</div>
<div class="mb-3">
<label class="form-label">Confirm new password</label>
<input type="password" name="confirmPassword" class="form-control bg-dark text-white" autocomplete="new-password" minlength="8" required />
</div>
<button type="submit" class="btn btn-primary">Update password</button>
}
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="bg-dark border border-secondary rounded p-4 text-white h-100">
<h4 class="mb-3">
Two-factor authentication
@if (enabled)
{
<span class="badge text-bg-success ms-2">Enabled</span>
}
else
{
<span class="badge text-bg-secondary ms-2">Disabled</span>
}
</h4>
@if (TempData["tfaOk"] != null)
{
<div class="alert alert-success py-2">@TempData["tfaOk"]</div>
}
@if (TempData["tfaError"] != null)
{
<div class="alert alert-danger py-2">@TempData["tfaError"]</div>
}
@if (enabled)
{
<p class="text-white-50">2FA is active. Enter a current authenticator code to disable it.</p>
using (Html.BeginForm("DisableTwoFactor", "admin", FormMethod.Post))
{
@Html.AntiForgeryToken()
<div class="mb-3">
<label class="form-label">Authenticator code</label>
<input type="text" name="code" class="form-control bg-dark text-white" maxlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" required />
</div>
<button type="submit" class="btn btn-danger" onclick="return confirm('Disable two-factor authentication?');">Disable 2FA</button>
}
}
else
{
<ol class="text-white-50">
<li>Open your authenticator app and scan the QR code below.</li>
<li>Or enter this secret manually: <code class="text-white">@ViewBag.SetupSecret</code></li>
<li>Enter the 6-digit code to confirm and enable 2FA.</li>
</ol>
<div class="text-center my-3">
<div id="totpQr" class="d-inline-block bg-white p-2 rounded"></div>
</div>
using (Html.BeginForm("EnableTwoFactor", "admin", FormMethod.Post))
{
@Html.AntiForgeryToken()
<div class="mb-3">
<label class="form-label">Authenticator code</label>
<input type="text" name="code" class="form-control bg-dark text-white" maxlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" required />
</div>
<button type="submit" class="btn btn-success">Enable 2FA</button>
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
(function () {
var uri = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject((string)ViewBag.OtpAuthUri));
var el = document.getElementById('totpQr');
if (el && uri && typeof QRCode !== 'undefined') {
new QRCode(el, { text: uri, width: 200, height: 200 });
}
})();
</script>
}
</div>
</div>
</div>
+10 -21
View File
@@ -1,19 +1,15 @@
@{
@{
Layout = null;
}
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign-in</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.form-control {
background-color: #212529 !important;
@@ -30,16 +26,6 @@
box-shadow: 0 0 5px rgba(13, 110, 253, 0.5);
}
.form-check-input {
background-color: #343a40 !important;
border-color: #6c757d !important;
}
.form-check-input:checked {
background-color: #0d6efd !important;
border-color: #0d6efd !important;
}
.btn-primary {
background-color: #0d6efd !important;
border-color: #0d6efd !important;
@@ -51,28 +37,31 @@
}
</style>
</head>
<body class="bg-dark d-flex align-items-center justify-content-center vh-100">
<main class="form-signin w-100" style="max-width: 400px;">
@using (Html.BeginForm("index", "login", FormMethod.Post))
{
@Html.AntiForgeryToken()
<h1 class="h3 mb-3 fw-normal text-white text-center">Please sign in</h1>
if (ViewBag.Error != null)
{
<div class="alert alert-danger py-2">@ViewBag.Error</div>
}
<div class="form-floating">
<input type="text" name="username" class="form-control" id="floatingInput" placeholder="Username">
<input type="text" name="username" class="form-control" id="floatingInput" placeholder="Username" autocomplete="username" required>
<label for="floatingInput">Username</label>
</div>
<div class="form-floating mt-2 mb-2">
<input type="password" name="password" class="form-control" id="floatingPassword" placeholder="Password">
<div class="form-floating mt-2 mb-3">
<input type="password" name="password" class="form-control" id="floatingPassword" placeholder="Password" autocomplete="current-password" required>
<label for="floatingPassword">Password</label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
}
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
@{
Layout = null;
}
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Two-factor authentication</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" rel="stylesheet">
<style>
.form-control {
background-color: #212529 !important;
color: white !important;
border: 1px solid #6c757d;
letter-spacing: 0.35rem;
text-align: center;
font-size: 1.4rem;
}
.form-control:focus {
border-color: #0d6efd !important;
box-shadow: 0 0 5px rgba(13, 110, 253, 0.5);
}
</style>
</head>
<body class="bg-dark d-flex align-items-center justify-content-center vh-100">
<main class="w-100" style="max-width: 400px;">
@using (Html.BeginForm("Verify", "login", FormMethod.Post))
{
@Html.AntiForgeryToken()
<h1 class="h3 mb-2 fw-normal text-white text-center">Two-factor authentication</h1>
<p class="text-white-50 text-center mb-3">Enter the 6-digit code from your authenticator app.</p>
if (ViewBag.Error != null)
{
<div class="alert alert-danger py-2">@ViewBag.Error</div>
}
<div class="mb-3">
<input type="text" name="code" class="form-control" maxlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autofocus required />
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Verify</button>
<a href="@Url.Action("Index", "login")" class="btn btn-link text-white-50 w-100 mt-2">Back to sign in</a>
}
</main>
</body>
</html>
+275 -3
View File
@@ -37,6 +37,7 @@
width: 100%;
height: auto;
object-fit: contain;
cursor: zoom-in;
}
.media-arrow {
@@ -61,6 +62,91 @@
.carousel-indicators {
z-index: 6;
}
#projectLightboxModal .modal-dialog {
width: auto;
max-width: calc(100vw - 2rem);
margin: 0.5rem auto;
}
#projectLightboxModal .modal-content {
background: transparent;
width: fit-content;
max-width: 100%;
margin: 0 auto;
}
#projectLightboxModal .modal-body {
padding: 0;
}
#projectLightboxModal .lightbox-stage {
position: relative;
display: inline-block;
max-width: calc(100vw - 2rem);
line-height: 0;
background: #000;
border-radius: 0.35rem;
overflow: hidden;
}
#projectLightboxModal .lightbox-stage img {
display: block;
width: auto;
height: auto;
max-width: calc(100vw - 2rem);
max-height: 85vh;
object-fit: contain;
}
#projectLightboxModal .lightbox-arrow {
position: absolute;
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 3.5rem;
height: 3.5rem;
opacity: 0.9;
z-index: 2;
}
#projectLightboxModal .lightbox-arrow.carousel-control-prev {
left: 0;
}
#projectLightboxModal .lightbox-arrow.carousel-control-next {
right: 0;
}
#projectLightboxModal .lightbox-indicators {
position: absolute;
left: 0;
right: 0;
bottom: 0.75rem;
margin: 0;
z-index: 2;
}
#projectLightboxModal .lightbox-indicators button {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: rgba(255, 255, 255, .5);
border: 0;
margin: 0 4px;
padding: 0;
}
#projectLightboxModal .lightbox-indicators button.active {
background-color: #fff;
}
#projectLightboxModal .lightbox-close {
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 3;
}
</style>
@foreach (var p in Model)
@@ -104,7 +190,9 @@
<div class="carousel-media-iframe">
<iframe src="@m.url"
loading="lazy"
allowfullscreen></iframe>
allowfullscreen
referrerpolicy="no-referrer"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"></iframe>
@if (hasMultiple)
@@ -130,7 +218,11 @@
else if (m.type == "image")
{
<div class="carousel-media-image">
<img src="@m.url" alt="@p.title" />
<img src="@m.url"
alt="@p.title"
class="project-lightbox-trigger"
data-fullsrc="@m.url"
data-gallery="@carouselId" />
@if (hasMultiple)
{
@@ -177,4 +269,184 @@
<p class="lead mb-5 text-center">@Html.Raw(atakanozbancom.Models.classes.DescriptionFormatter.ToSafeHtml(p.description))</p>
</div>
}
}
<div class="modal fade" id="projectLightboxModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-none">
<div class="modal-body">
<div class="lightbox-stage">
<button type="button" class="btn-close btn-close-white lightbox-close" data-bs-dismiss="modal" aria-label="Close"></button>
<img id="projectLightboxImg" src="" alt="" />
<button type="button" id="lightboxPrev" class="carousel-control-prev lightbox-arrow" aria-label="Previous">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
</button>
<button type="button" id="lightboxNext" class="carousel-control-next lightbox-arrow" aria-label="Next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
</button>
<div id="lightboxIndicators" class="carousel-indicators lightbox-indicators d-none"></div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
var items = [];
var index = 0;
var modalEl = null;
var imgEl = null;
var stageEl = null;
var dialogEl = null;
var indicatorsEl = null;
var prevBtn = null;
var nextBtn = null;
function getEls() {
modalEl = document.getElementById('projectLightboxModal');
imgEl = document.getElementById('projectLightboxImg');
indicatorsEl = document.getElementById('lightboxIndicators');
prevBtn = document.getElementById('lightboxPrev');
nextBtn = document.getElementById('lightboxNext');
stageEl = modalEl ? modalEl.querySelector('.lightbox-stage') : null;
dialogEl = modalEl ? modalEl.querySelector('.modal-dialog') : null;
return modalEl && imgEl && indicatorsEl && prevBtn && nextBtn && stageEl && dialogEl;
}
function fitToImage() {
var maxW = Math.max(window.innerWidth - 32, 200);
var maxH = Math.max(window.innerHeight * 0.85, 200);
var nw = imgEl.naturalWidth || 0;
var nh = imgEl.naturalHeight || 0;
if (!nw || !nh) return;
var scale = Math.min(maxW / nw, maxH / nh, 1);
var w = Math.round(nw * scale);
var h = Math.round(nh * scale);
imgEl.style.width = w + 'px';
imgEl.style.height = h + 'px';
stageEl.style.width = w + 'px';
stageEl.style.height = h + 'px';
dialogEl.style.width = w + 'px';
dialogEl.style.maxWidth = w + 'px';
}
function showSlide(i) {
if (!items.length) return;
index = (i + items.length) % items.length;
var item = items[index];
imgEl.onload = fitToImage;
imgEl.src = item.src;
imgEl.alt = item.alt;
if (imgEl.complete && imgEl.naturalWidth) fitToImage();
var dots = indicatorsEl.querySelectorAll('button');
for (var d = 0; d < dots.length; d++) {
dots[d].classList.toggle('active', d === index);
if (d === index) dots[d].setAttribute('aria-current', 'true');
else dots[d].removeAttribute('aria-current');
}
}
function buildIndicators() {
indicatorsEl.innerHTML = '';
if (items.length < 2) {
indicatorsEl.classList.add('d-none');
prevBtn.classList.add('d-none');
nextBtn.classList.add('d-none');
return;
}
indicatorsEl.classList.remove('d-none');
prevBtn.classList.remove('d-none');
nextBtn.classList.remove('d-none');
for (var i = 0; i < items.length; i++) {
(function (slideIndex) {
var btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('aria-label', 'Slide ' + (slideIndex + 1));
if (slideIndex === index) {
btn.classList.add('active');
btn.setAttribute('aria-current', 'true');
}
btn.addEventListener('click', function () { showSlide(slideIndex); });
indicatorsEl.appendChild(btn);
})(i);
}
}
document.addEventListener('click', function (e) {
var trigger = e.target.closest('.project-lightbox-trigger');
if (!trigger) return;
if (typeof bootstrap === 'undefined') return;
if (!getEls()) return;
e.preventDefault();
var gallery = trigger.getAttribute('data-gallery');
var galleryImgs = gallery
? document.querySelectorAll('.project-lightbox-trigger[data-gallery="' + gallery + '"]')
: [trigger];
items = [];
index = 0;
for (var i = 0; i < galleryImgs.length; i++) {
var img = galleryImgs[i];
items.push({
src: img.getAttribute('data-fullsrc') || img.src,
alt: img.alt || ''
});
if (img === trigger) index = i;
}
buildIndicators();
showSlide(index);
bootstrap.Modal.getOrCreateInstance(modalEl).show();
});
document.addEventListener('click', function (e) {
if (e.target.closest('#lightboxPrev')) {
e.preventDefault();
showSlide(index - 1);
} else if (e.target.closest('#lightboxNext')) {
e.preventDefault();
showSlide(index + 1);
}
});
document.addEventListener('keydown', function (e) {
if (!modalEl || !modalEl.classList.contains('show')) return;
if (e.key === 'ArrowLeft') showSlide(index - 1);
if (e.key === 'ArrowRight') showSlide(index + 1);
});
window.addEventListener('resize', function () {
if (modalEl && modalEl.classList.contains('show')) fitToImage();
});
document.addEventListener('hidden.bs.modal', function (e) {
if (e.target.id !== 'projectLightboxModal') return;
if (!imgEl) getEls();
if (imgEl) {
imgEl.onload = null;
imgEl.removeAttribute('src');
imgEl.style.width = '';
imgEl.style.height = '';
}
if (stageEl) {
stageEl.style.width = '';
stageEl.style.height = '';
}
if (dialogEl) {
dialogEl.style.width = '';
dialogEl.style.maxWidth = '';
}
items = [];
index = 0;
});
})();
</script>