153 lines
5.3 KiB
C#
153 lines
5.3 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace JJServiceManagerRelay;
|
|
|
|
/// <summary>비밀번호 해시, 토큰, 코드 만들기</summary>
|
|
public static class Secrets
|
|
{
|
|
private const int Iterations = 210_000;
|
|
|
|
public static string HashPassword(string password)
|
|
{
|
|
var salt = RandomNumberGenerator.GetBytes(16);
|
|
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, 32);
|
|
return $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
|
}
|
|
|
|
public static bool VerifyPassword(string password, string stored)
|
|
{
|
|
try
|
|
{
|
|
var p = stored.Split('$');
|
|
if (p.Length != 4 || p[0] != "pbkdf2-sha256") return false;
|
|
int iter = int.Parse(p[1]);
|
|
var salt = Convert.FromBase64String(p[2]);
|
|
var expect = Convert.FromBase64String(p[3]);
|
|
var got = Rfc2898DeriveBytes.Pbkdf2(password ?? "", salt, iter, HashAlgorithmName.SHA256, expect.Length);
|
|
return CryptographicOperations.FixedTimeEquals(got, expect);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>비교용 비밀번호를 늘 계산해서 "없는 아이디"도 같은 시간이 걸리게</summary>
|
|
public static readonly string DummyHash = HashPassword(Guid.NewGuid().ToString());
|
|
|
|
public static string NewToken() => Base64Url(RandomNumberGenerator.GetBytes(32));
|
|
|
|
public static byte[] Sha256(string s) => SHA256.HashData(Encoding.UTF8.GetBytes(s ?? ""));
|
|
|
|
/// <summary>등록 코드: XXXX-XXXX (헷갈리는 글자 O/0/I/1 제외)</summary>
|
|
public static string NewCode()
|
|
{
|
|
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < 8; i++)
|
|
{
|
|
if (i == 4) sb.Append('-');
|
|
sb.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>입력한 코드 정리 (대소문자·공백·하이픈 무시)</summary>
|
|
public static string NormalizeCode(string code)
|
|
{
|
|
var s = new string((code ?? "").ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray());
|
|
return s.Length == 8 ? s[..4] + "-" + s[4..] : s;
|
|
}
|
|
|
|
public static string Base64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
|
|
public static byte[] FromBase64Url(string s)
|
|
{
|
|
s = s.Replace('-', '+').Replace('_', '/');
|
|
switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; }
|
|
return Convert.FromBase64String(s);
|
|
}
|
|
}
|
|
|
|
/// <summary>권한 티켓 서명 (ECDSA P-256). 대상 PC가 서버 공개키로 확인한다.</summary>
|
|
public sealed class TicketSigner
|
|
{
|
|
private readonly ECDsa _key;
|
|
public string PublicKeyBase64 { get; }
|
|
|
|
public TicketSigner(string pkcs8Base64)
|
|
{
|
|
_key = ECDsa.Create();
|
|
_key.ImportPkcs8PrivateKey(Convert.FromBase64String(pkcs8Base64), out _);
|
|
PublicKeyBase64 = Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo());
|
|
}
|
|
|
|
public static string NewPrivateKey()
|
|
{
|
|
using var k = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
return Convert.ToBase64String(k.ExportPkcs8PrivateKey());
|
|
}
|
|
|
|
/// <summary>티켓 = base64url(JSON) + "." + base64url(서명)</summary>
|
|
public string Sign(JsonObject payload)
|
|
{
|
|
var body = Encoding.UTF8.GetBytes(payload.ToJsonString());
|
|
var sig = _key.SignData(body, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
|
return Secrets.Base64Url(body) + "." + Secrets.Base64Url(sig);
|
|
}
|
|
}
|
|
|
|
/// <summary>로그인·등록 실패 제한: 5분 동안 10번 넘게 틀리면 15분 거부</summary>
|
|
public sealed class Throttle
|
|
{
|
|
private readonly ConcurrentDictionary<string, List<DateTime>> _fails = new();
|
|
private readonly ConcurrentDictionary<string, DateTime> _blocked = new();
|
|
private static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
|
|
private static readonly TimeSpan Block = TimeSpan.FromMinutes(15);
|
|
private const int MaxFails = 10;
|
|
|
|
public bool IsBlocked(params string[] keys)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
foreach (var k in keys)
|
|
{
|
|
if (k == null) continue;
|
|
if (_blocked.TryGetValue(k, out var until))
|
|
{
|
|
if (until > now) return true;
|
|
_blocked.TryRemove(k, out _);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public void Fail(params string[] keys)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
foreach (var k in keys)
|
|
{
|
|
if (k == null) continue;
|
|
var list = _fails.GetOrAdd(k, _ => new List<DateTime>());
|
|
lock (list)
|
|
{
|
|
list.Add(now);
|
|
list.RemoveAll(t => now - t > Window);
|
|
if (list.Count > MaxFails)
|
|
{
|
|
_blocked[k] = now + Block;
|
|
list.Clear();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Success(params string[] keys)
|
|
{
|
|
foreach (var k in keys)
|
|
if (k != null) _fails.TryRemove(k, out _);
|
|
}
|
|
}
|