mirror of
https://github.com/gnh1201/welsonjs.git
synced 2026-02-17 08:08:26 +00:00
Refactored all API endpoint classes from the ResourceTools namespace to the ApiEndpoints namespace for improved clarity and organization. Updated all references in ResourceServer to use the new namespace.
223 lines
7.7 KiB
C#
223 lines
7.7 KiB
C#
// TwoFactorAuth.cs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
|
|
// https://github.com/gnh1201/welsonjs
|
|
//
|
|
using log4net;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace WelsonJS.Launcher.ApiEndpoints
|
|
{
|
|
public class TwoFactorAuth : IApiEndpoint
|
|
{
|
|
private readonly ResourceServer Server;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILog _logger;
|
|
private const string Prefix = "tfa/";
|
|
private const string Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
private static readonly int[] ValidKeyCharLengths = new[] { 16, 32 };
|
|
|
|
public TwoFactorAuth(ResourceServer server, HttpClient httpClient, ILog logger)
|
|
{
|
|
Server = server;
|
|
|
|
_httpClient = httpClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
public bool CanHandle(HttpListenerContext context, string path)
|
|
{
|
|
return path != null && path.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public async Task HandleAsync(HttpListenerContext context, string path)
|
|
{
|
|
string endpoint = path.Substring(Prefix.Length);
|
|
|
|
// GET /tfa/pubkey[?len=16|32]
|
|
if (endpoint.Equals("pubkey", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int length = 32;
|
|
var q = context.Request?.QueryString?["len"];
|
|
if (!string.IsNullOrEmpty(q) &&
|
|
int.TryParse(q, out var parsed) &&
|
|
Array.IndexOf(ValidKeyCharLengths, parsed) >= 0)
|
|
{
|
|
length = parsed;
|
|
}
|
|
|
|
await Server.ServeResource(context, GetPubKey(length), "text/plain", 200);
|
|
return;
|
|
}
|
|
|
|
// POST /tfa/otp
|
|
// Body: secret=BASE32KEY
|
|
if (endpoint.Equals("otp", StringComparison.OrdinalIgnoreCase) &&
|
|
context.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
string body;
|
|
using (var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding))
|
|
{
|
|
body = await reader.ReadToEndAsync();
|
|
}
|
|
|
|
var parsed = ParseFormEncoded(body);
|
|
if (!parsed.TryGetValue("secret", out string secret) || string.IsNullOrWhiteSpace(secret))
|
|
{
|
|
await Server.ServeResource(context, "missing 'secret' parameter", "text/plain", 400);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
int otp = GetOtp(secret.Trim());
|
|
string otp6 = otp.ToString("D6"); // always 6 digits
|
|
await Server.ServeResource(context, otp6, "text/plain", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await Server.ServeResource(context, $"invalid secret: {ex.Message}", "text/plain", 400);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Default: not found
|
|
await Server.ServeResource(context, "not found", "text/plain", 404);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compute a 6-digit TOTP from a Base32 secret.
|
|
/// </summary>
|
|
public int GetOtp(string key, int period = 30)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
throw new ArgumentException("Secret key is required.", nameof(key));
|
|
|
|
string normalized = NormalizeBase32(key);
|
|
byte[] binaryKey = DecodeBase32(normalized);
|
|
if (binaryKey.Length == 0)
|
|
throw new ArgumentException("Secret could not be decoded.", nameof(key));
|
|
|
|
long timestep = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
|
|
byte[] msg = BitConverter.GetBytes(timestep);
|
|
if (BitConverter.IsLittleEndian) Array.Reverse(msg);
|
|
|
|
using (var hmac = new HMACSHA1(binaryKey))
|
|
{
|
|
byte[] hash = hmac.ComputeHash(msg);
|
|
int offset = hash[hash.Length - 1] & 0x0F;
|
|
|
|
int binCode = ((hash[offset] & 0x7F) << 24)
|
|
| ((hash[offset + 1] & 0xFF) << 16)
|
|
| ((hash[offset + 2] & 0xFF) << 8)
|
|
| (hash[offset + 3] & 0xFF);
|
|
|
|
return binCode % 1_000_000; // fixed 6 digits
|
|
}
|
|
}
|
|
|
|
public string GetPubKey(int charCount = 32)
|
|
{
|
|
if (Array.IndexOf(ValidKeyCharLengths, charCount) < 0)
|
|
throw new ArgumentException("charCount must be 16 or 32.", nameof(charCount));
|
|
|
|
int bytesLen = (charCount * 5) / 8;
|
|
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
{
|
|
var randomBytes = new byte[bytesLen];
|
|
rng.GetBytes(randomBytes);
|
|
|
|
string b32 = EncodeBase32(randomBytes);
|
|
return b32.ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
private static string NormalizeBase32(string s)
|
|
{
|
|
var sb = new StringBuilder(s.Length);
|
|
foreach (var ch in s)
|
|
{
|
|
if (ch == ' ' || ch == '-') continue;
|
|
sb.Append(char.ToUpperInvariant(ch));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string EncodeBase32(byte[] data)
|
|
{
|
|
if (data == null || data.Length == 0) return string.Empty;
|
|
|
|
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
|
|
int buffer = 0, bitsLeft = 0;
|
|
|
|
foreach (byte b in data)
|
|
{
|
|
buffer = (buffer << 8) | b;
|
|
bitsLeft += 8;
|
|
while (bitsLeft >= 5)
|
|
{
|
|
sb.Append(Base32Chars[(buffer >> (bitsLeft - 5)) & 31]);
|
|
bitsLeft -= 5;
|
|
}
|
|
}
|
|
|
|
if (bitsLeft > 0)
|
|
sb.Append(Base32Chars[(buffer << (5 - bitsLeft)) & 31]);
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static byte[] DecodeBase32(string key)
|
|
{
|
|
int buffer = 0, bitsLeft = 0;
|
|
var bytes = new List<byte>(key.Length * 5 / 8);
|
|
|
|
foreach (char raw in key)
|
|
{
|
|
char c = char.ToUpperInvariant(raw);
|
|
if (c == ' ' || c == '-') continue;
|
|
|
|
int v = Base32Chars.IndexOf(c);
|
|
if (v < 0) continue;
|
|
|
|
buffer = (buffer << 5) | v;
|
|
bitsLeft += 5;
|
|
if (bitsLeft >= 8)
|
|
{
|
|
bitsLeft -= 8;
|
|
bytes.Add((byte)((buffer >> bitsLeft) & 0xFF));
|
|
}
|
|
}
|
|
return bytes.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse application/x-www-form-urlencoded into a dictionary (UTF-8 assumed).
|
|
/// </summary>
|
|
private static Dictionary<string, string> ParseFormEncoded(string body)
|
|
{
|
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
if (string.IsNullOrEmpty(body)) return result;
|
|
|
|
var pairs = body.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries);
|
|
foreach (var pair in pairs)
|
|
{
|
|
var kv = pair.Split(new[] { '=' }, 2);
|
|
string key = Uri.UnescapeDataString(kv[0] ?? "");
|
|
string value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : "";
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
}
|