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
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace atakanozbancom.Models.classes
{
public static class PasswordHasher
{
private const string Prefix = "pbkdf2";
private const int DefaultIterations = 100000;
private const int SaltSize = 16;
private const int HashSize = 32;
public static string Hash(string password)
{
if (password == null)
throw new ArgumentNullException("password");
var salt = new byte[SaltSize];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(salt);
}
var hash = Pbkdf2(password, salt, DefaultIterations, HashSize);
return string.Format(
"{0}${1}${2}${3}",
Prefix,
DefaultIterations,
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
public static bool IsHashed(string stored)
{
return !string.IsNullOrWhiteSpace(stored)
&& stored.StartsWith(Prefix + "$", StringComparison.Ordinal);
}
public static bool Verify(string password, string stored)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(stored))
return false;
if (!IsHashed(stored))
return FixedTimeEquals(password, stored);
var parts = stored.Split('$');
if (parts.Length != 4 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal))
return false;
int iterations;
if (!int.TryParse(parts[1], out iterations) || iterations < 1000)
return false;
byte[] salt;
byte[] expected;
try
{
salt = Convert.FromBase64String(parts[2]);
expected = Convert.FromBase64String(parts[3]);
}
catch
{
return false;
}
var actual = Pbkdf2(password, salt, iterations, expected.Length);
return FixedTimeEquals(actual, expected);
}
private static byte[] Pbkdf2(string password, byte[] salt, int iterations, int length)
{
using (var derive = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256))
{
return derive.GetBytes(length);
}
}
private static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
private static bool FixedTimeEquals(string a, string b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Text;
using System.Web.Security;
namespace atakanozbancom.Models.classes
{
public static class SecretProtector
{
private static readonly string[] Purposes = { "atakanozbancom.TotpSecret.v1" };
public static string Protect(string plaintext)
{
if (string.IsNullOrEmpty(plaintext))
return plaintext;
var bytes = Encoding.UTF8.GetBytes(plaintext);
var protectedBytes = MachineKey.Protect(bytes, Purposes);
return Convert.ToBase64String(protectedBytes);
}
public static string Unprotect(string protectedValue)
{
if (string.IsNullOrEmpty(protectedValue))
return protectedValue;
try
{
var protectedBytes = Convert.FromBase64String(protectedValue);
var bytes = MachineKey.Unprotect(protectedBytes, Purposes);
return bytes == null ? null : Encoding.UTF8.GetString(bytes);
}
catch
{
return null;
}
}
public static bool LooksProtected(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
try
{
var bytes = Convert.FromBase64String(value);
return bytes.Length > 16;
}
catch
{
return false;
}
}
}
}
+176
View File
@@ -0,0 +1,176 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Web;
namespace atakanozbancom.Models.classes
{
public static class TotpHelper
{
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static string GenerateSecret(int byteLength = 20)
{
var bytes = new byte[byteLength];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
return ToBase32(bytes);
}
public static bool VerifyCode(string base32Secret, string code, int window = 1)
{
if (string.IsNullOrWhiteSpace(base32Secret) || string.IsNullOrWhiteSpace(code))
return false;
code = code.Trim().Replace(" ", "");
if (code.Length != 6)
return false;
long timestep;
try
{
timestep = GetCurrentTimeStep();
}
catch
{
return false;
}
for (var i = -window; i <= window; i++)
{
var expected = ComputeTotp(base32Secret, timestep + i);
if (FixedTimeEquals(expected, code))
return true;
}
return false;
}
public static string BuildOtpAuthUri(string issuer, string accountName, string base32Secret)
{
var label = HttpUtility.UrlEncode(issuer + ":" + accountName);
var issuerParam = HttpUtility.UrlEncode(issuer);
return string.Format(
CultureInfo.InvariantCulture,
"otpauth://totp/{0}?secret={1}&issuer={2}&digits=6&period=30",
label,
base32Secret,
issuerParam);
}
private static long GetCurrentTimeStep()
{
return (long)Math.Floor((DateTime.UtcNow - UnixEpoch).TotalSeconds / 30.0);
}
private static string ComputeTotp(string base32Secret, long timestep)
{
var key = FromBase32(base32Secret);
var counter = BitConverter.GetBytes(timestep);
if (BitConverter.IsLittleEndian)
Array.Reverse(counter);
byte[] hash;
using (var hmac = new HMACSHA1(key))
{
hash = hmac.ComputeHash(counter);
}
var offset = hash[hash.Length - 1] & 0x0F;
var binary =
((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
var otp = binary % 1000000;
return otp.ToString("D6", CultureInfo.InvariantCulture);
}
private static string ToBase32(byte[] data)
{
if (data == null || data.Length == 0)
return string.Empty;
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = data[0];
var next = 1;
var bitsLeft = 8;
while (bitsLeft > 0 || next < data.Length)
{
if (bitsLeft < 5)
{
if (next < data.Length)
{
buffer <<= 8;
buffer |= data[next++] & 0xFF;
bitsLeft += 8;
}
else
{
var pad = 5 - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
var index = (buffer >> (bitsLeft - 5)) & 0x1F;
bitsLeft -= 5;
sb.Append(Base32Alphabet[index]);
}
return sb.ToString();
}
private static byte[] FromBase32(string input)
{
var cleaned = input.Trim().Replace(" ", "").Replace("=", "").ToUpperInvariant();
var output = new byte[cleaned.Length * 5 / 8];
var bitBuffer = 0;
var bitsLeft = 0;
var index = 0;
foreach (var c in cleaned)
{
var val = Base32Alphabet.IndexOf(c);
if (val < 0)
throw new FormatException("Invalid Base32 character.");
bitBuffer = (bitBuffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8)
{
output[index++] = (byte)((bitBuffer >> (bitsLeft - 8)) & 0xFF);
bitsLeft -= 8;
}
}
if (index != output.Length)
{
var trimmed = new byte[index];
Array.Copy(output, trimmed, index);
return trimmed;
}
return output;
}
private static bool FixedTimeEquals(string a, string b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
}
}
+24 -2
View File
@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
@@ -9,6 +11,26 @@ namespace atakanozbancom.Models.classes
public int id { get; set; }
public string username { get; set; }
public string password { get; set; }
[Column("two_factor_enabled")]
public bool two_factor_enabled { get; set; }
[Column("two_factor_secret")]
[StringLength(512)]
public string two_factor_secret { get; set; }
[Column("login_failed_count")]
public int login_failed_count { get; set; }
[Column("login_lock_until")]
public DateTime? login_lock_until { get; set; }
[Column("totp_failed_count")]
public int totp_failed_count { get; set; }
[Column("totp_lock_until")]
public DateTime? totp_lock_until { get; set; }
public List<myprojects> MyProjects { get; set; }
}
}
}