feat(auth): TOTP 2FA для админов через AuthenticatorTokenProvider (P2-4)
ASP.NET Identity AuthenticatorTokenProvider (RFC 6238 — Google
Authenticator, Authy, 1Password OTP). TwoFactorEnabled и SecurityStamp
уже были в users-таблице из Identity-схемы.
Endpoints (Bearer-auth):
- GET /api/me/2fa/status — { enabled: bool }.
- POST /api/me/2fa/enroll — генерирует SecretKey (если ещё нет),
возвращает otpauth-URI для QR + сам shared-key. Пока 2FA включён,
enroll возвращает alreadyEnabled=true без секрета.
- POST /api/me/2fa/verify { code } — валидирует и включает 2FA.
- POST /api/me/2fa/disable { code } — выключает + ResetAuthenticatorKeyAsync.
Требует текущий code как защиту от случайного отключения.
AuthorizationController.Exchange (password grant): после успеха проверки
пароля смотрит TwoFactorEnabledAsync; если true и нет otp_code в
запросе — возвращает invalid_grant с error_description="2fa_required";
если otp_code невалиден — "2fa_invalid"; иначе токен выдаётся.
Опционально для всех ролей — User самостоятельно решает включать или нет.
Для админов рекомендуется (отдельная политика — следующий шаг).
Тесты: 4 интеграционных (enroll+verify+status, неверный code → 400,
token-endpoint require otp_code, disable с code). Тесты сами генерируют
TOTP через ручную RFC 6238 имплементацию (HMAC-SHA1, 30-сек step).
Bonus: добавлены DI-заглушки UnusedSupplyWriter / UnusedRetailSalePoster
для CQRS-handler'ов из TD-1 — handler'ы пока не подключены к
контроллерам, заглушки нужны чтобы DI-validation на старте не падала.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
971c9b29a5
commit
7b7a7091b9
|
|
@ -48,6 +48,26 @@ public async Task<IActionResult> Exchange()
|
||||||
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
|
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
|
||||||
|
// (custom parameter, OpenIddict передаёт его через request['otp_code']).
|
||||||
|
// Это второй шаг password-flow без редиректа: клиент видит
|
||||||
|
// invalid_grant + error_description="2fa_required" и шлёт ту же
|
||||||
|
// password-форму ещё раз с otp_code.
|
||||||
|
if (await _userManager.GetTwoFactorEnabledAsync(user))
|
||||||
|
{
|
||||||
|
var otp = request["otp_code"]?.Value?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(otp))
|
||||||
|
{
|
||||||
|
return BadRequestError(Errors.InvalidGrant, "2fa_required");
|
||||||
|
}
|
||||||
|
var validProvider = _userManager.Options.Tokens.AuthenticatorTokenProvider;
|
||||||
|
var ok = await _userManager.VerifyTwoFactorTokenAsync(user, validProvider, otp);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return BadRequestError(Errors.InvalidGrant, "2fa_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
|
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
|
||||||
if (rejection is not null) return rejection;
|
if (rejection is not null) return rejection;
|
||||||
|
|
||||||
|
|
|
||||||
117
src/food-market.api/Controllers/TwoFactorController.cs
Normal file
117
src/food-market.api/Controllers/TwoFactorController.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using System.Web;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>TOTP-2FA для текущего пользователя. Использует встроенный
|
||||||
|
/// <c>AuthenticatorTokenProvider</c> ASP.NET Identity — RFC 6238 (Google
|
||||||
|
/// Authenticator, Authy, 1Password OTP).
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. POST <c>/api/me/2fa/enroll</c> — генерирует секрет (если ещё нет) и
|
||||||
|
/// возвращает QR-URI (otpauth://) + сам ключ. Клиент рисует QR.
|
||||||
|
/// 2. POST <c>/api/me/2fa/verify</c> с code из приложения — проверяет и
|
||||||
|
/// включает <c>TwoFactorEnabled</c>. После этого password-flow требует
|
||||||
|
/// otp_code.
|
||||||
|
/// 3. POST <c>/api/me/2fa/disable</c> с code — выключает.
|
||||||
|
///
|
||||||
|
/// Дополнительной фичи (backup-коды) пока нет — добавим если попросят.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/me/2fa")]
|
||||||
|
public class TwoFactorController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UserManager<User> _users;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
|
||||||
|
public TwoFactorController(UserManager<User> users, AppDbContext db, IConfiguration cfg)
|
||||||
|
{
|
||||||
|
_users = users;
|
||||||
|
_db = db;
|
||||||
|
_cfg = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled);
|
||||||
|
public record CodeInput(string Code);
|
||||||
|
public record StatusDto(bool Enabled);
|
||||||
|
|
||||||
|
[HttpGet("status")]
|
||||||
|
public async Task<ActionResult<StatusDto>> Status()
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
return new StatusDto(await _users.GetTwoFactorEnabledAsync(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("enroll")]
|
||||||
|
public async Task<ActionResult<EnrollResult>> Enroll()
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
var already = await _users.GetTwoFactorEnabledAsync(user);
|
||||||
|
// Если уже включено — не отдаём секрет; сначала disable.
|
||||||
|
if (already)
|
||||||
|
return Ok(new EnrollResult("", "", true));
|
||||||
|
|
||||||
|
var key = await _users.GetAuthenticatorKeyAsync(user);
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
await _users.ResetAuthenticatorKeyAsync(user);
|
||||||
|
key = await _users.GetAuthenticatorKeyAsync(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
var issuer = _cfg["App:Name"] ?? "food-market";
|
||||||
|
var label = $"{issuer}:{user.Email ?? user.UserName}";
|
||||||
|
// otpauth-URI согласно RFC: otpauth://totp/{label}?secret={key}&issuer={issuer}.
|
||||||
|
var uri = $"otpauth://totp/{HttpUtility.UrlEncode(label)}?secret={key}&issuer={HttpUtility.UrlEncode(issuer)}";
|
||||||
|
|
||||||
|
return Ok(new EnrollResult(key!, uri, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("verify")]
|
||||||
|
public async Task<IActionResult> Verify([FromBody] CodeInput input)
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Code))
|
||||||
|
return BadRequest(new { error = "Не указан код подтверждения.", field = "code" });
|
||||||
|
|
||||||
|
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
|
||||||
|
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
|
||||||
|
if (!ok)
|
||||||
|
return BadRequest(new { error = "Неверный код. Попробуйте ещё раз.", field = "code" });
|
||||||
|
|
||||||
|
await _users.SetTwoFactorEnabledAsync(user, true);
|
||||||
|
return Ok(new { enabled = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("disable")]
|
||||||
|
public async Task<IActionResult> Disable([FromBody] CodeInput input)
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (!await _users.GetTwoFactorEnabledAsync(user))
|
||||||
|
return Ok(new { enabled = false });
|
||||||
|
|
||||||
|
// Защита от внезапного отключения 2FA: требуем текущий code из приложения.
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Code))
|
||||||
|
return BadRequest(new { error = "Подтверди отключение текущим кодом из приложения.", field = "code" });
|
||||||
|
|
||||||
|
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
|
||||||
|
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
|
||||||
|
if (!ok)
|
||||||
|
return BadRequest(new { error = "Неверный код.", field = "code" });
|
||||||
|
|
||||||
|
await _users.SetTwoFactorEnabledAsync(user, false);
|
||||||
|
// Заодно сбрасываем authenticator-key, чтобы при повторном enroll выдался новый.
|
||||||
|
await _users.ResetAuthenticatorKeyAsync(user);
|
||||||
|
return Ok(new { enabled = false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
using foodmarket.Application.Purchases.Commands;
|
||||||
|
using foodmarket.Application.Sales.Commands;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Cqrs;
|
||||||
|
|
||||||
|
/// <summary>Заглушки для абстракций CQRS-handler'ов из food-market.application:
|
||||||
|
/// контроллеры в текущем спринте остаются на прямом доступе к DbContext
|
||||||
|
/// (поэтапная миграция к CQRS), эти классы нужны только чтобы DI-validation
|
||||||
|
/// на старте не падал при попытке активировать handler.
|
||||||
|
///
|
||||||
|
/// Когда контроллер начнёт реально слать команду через IMediator, заменяем
|
||||||
|
/// эти стабы на реальную имплементацию (через EF, IStockService и т.п.).
|
||||||
|
/// При случайной активации throw'аем — phantom-инициализация не должна молча
|
||||||
|
/// проглатывать команды.</summary>
|
||||||
|
public sealed class UnusedSupplyWriter : ISupplyWriter
|
||||||
|
{
|
||||||
|
public Task<string> NextNumberAsync(int year, CancellationToken ct)
|
||||||
|
=> throw new NotImplementedException(
|
||||||
|
"CreateSupplyHandler ещё не подключён к контроллерам. " +
|
||||||
|
"Контроллер использует прямой доступ к EF; этот writer существует только как образец CQRS-абстракции.");
|
||||||
|
|
||||||
|
public Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
|
||||||
|
DateTime date, string? notes, string number,
|
||||||
|
IReadOnlyList<CreateSupplyLine> lines, decimal total, CancellationToken ct)
|
||||||
|
=> throw new NotImplementedException(
|
||||||
|
"CreateSupplyHandler ещё не подключён к контроллерам.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UnusedRetailSalePoster : IRetailSalePoster
|
||||||
|
{
|
||||||
|
public Task PostAsync(Guid saleId, IReadOnlyList<PostSaleLine> lines, CancellationToken ct)
|
||||||
|
=> throw new NotImplementedException(
|
||||||
|
"PostRetailSaleHandler ещё не подключён к контроллерам. " +
|
||||||
|
"Контроллер RetailSalesController.Post использует прямой stock-service.");
|
||||||
|
}
|
||||||
|
|
@ -174,6 +174,14 @@
|
||||||
// переписаны под mediator — это паттерн-показ, не полный рефакторинг.
|
// переписаны под mediator — это паттерн-показ, не полный рефакторинг.
|
||||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
|
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
|
||||||
typeof(foodmarket.Application.Inventory.IStockService).Assembly));
|
typeof(foodmarket.Application.Inventory.IStockService).Assembly));
|
||||||
|
// Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены
|
||||||
|
// к контроллерам (поэтапная миграция). Реальная имплементация появится
|
||||||
|
// когда контроллер начнёт отправлять команду через IMediator. Сейчас
|
||||||
|
// нужны только чтобы DI-validation на старте не падала.
|
||||||
|
builder.Services.AddTransient<foodmarket.Application.Purchases.Commands.ISupplyWriter,
|
||||||
|
foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
|
||||||
|
builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
|
||||||
|
foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
|
||||||
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
|
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
|
||||||
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
|
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
|
||||||
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
|
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
|
||||||
|
|
|
||||||
162
tests/food-market.IntegrationTests/TwoFactorTests.cs
Normal file
162
tests/food-market.IntegrationTests/TwoFactorTests.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class TwoFactorTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public TwoFactorTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
/// <summary>Enroll возвращает SharedKey + otpauth-URI; Verify с правильным
|
||||||
|
/// кодом включает 2FA; Status показывает enabled.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Enroll_verify_status_workflow()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"tfa-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
// 1) Изначально 2FA выключен.
|
||||||
|
var status1 = await api.GetJsonAsync("/api/me/2fa/status");
|
||||||
|
status1.GetProperty("enabled").GetBoolean().Should().BeFalse();
|
||||||
|
|
||||||
|
// 2) Enroll → секрет.
|
||||||
|
var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
|
||||||
|
enroll.EnsureSuccessStatusCode();
|
||||||
|
var enrollResp = await enroll.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var sharedKey = enrollResp.GetProperty("sharedKey").GetString();
|
||||||
|
var uri = enrollResp.GetProperty("authenticatorUri").GetString();
|
||||||
|
sharedKey.Should().NotBeNullOrWhiteSpace();
|
||||||
|
uri.Should().Contain("otpauth://totp/");
|
||||||
|
uri.Should().Contain(sharedKey);
|
||||||
|
|
||||||
|
// 3) Сгенерировать TOTP-код по тому же RFC 6238 и Verify.
|
||||||
|
var code = GenerateTotp(sharedKey!);
|
||||||
|
var verify = await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code });
|
||||||
|
verify.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// 4) Status теперь enabled.
|
||||||
|
var status2 = await api.GetJsonAsync("/api/me/2fa/status");
|
||||||
|
status2.GetProperty("enabled").GetBoolean().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wrong code → 400.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Verify_with_wrong_code_returns_400()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"tfa-wrong-{Guid.NewGuid():N}");
|
||||||
|
await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
|
||||||
|
using var r = await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = "000000" });
|
||||||
|
((int)r.StatusCode).Should().Be(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>После включения 2FA password-grant без otp_code → invalid_grant
|
||||||
|
/// с описанием "2fa_required". С правильным otp_code — token выдаётся.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Token_endpoint_requires_otp_when_2fa_enabled()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
var (email, password) = await api.SignupAndLoginAsync($"tfa-tok-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
|
||||||
|
var enrollResp = await enroll.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var sharedKey = enrollResp.GetProperty("sharedKey").GetString();
|
||||||
|
await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = GenerateTotp(sharedKey!) });
|
||||||
|
|
||||||
|
// Без otp_code → invalid_grant.
|
||||||
|
using var noOtp = await api.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = email,
|
||||||
|
["password"] = password,
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api offline_access",
|
||||||
|
}));
|
||||||
|
((int)noOtp.StatusCode).Should().Be(400);
|
||||||
|
var noOtpBody = await noOtp.Content.ReadAsStringAsync();
|
||||||
|
noOtpBody.Should().Contain("2fa_required");
|
||||||
|
|
||||||
|
// С правильным otp_code → success.
|
||||||
|
// TOTP-step 30 секунд; в тестах потенциально надо ждать новый шаг —
|
||||||
|
// используем тот же код, что прошёл при verify (в пределах окна).
|
||||||
|
using var withOtp = await api.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = email,
|
||||||
|
["password"] = password,
|
||||||
|
["client_id"] = "food-market-web",
|
||||||
|
["scope"] = "openid profile email roles api offline_access",
|
||||||
|
["otp_code"] = GenerateTotp(sharedKey!),
|
||||||
|
}));
|
||||||
|
withOtp.IsSuccessStatusCode.Should().BeTrue(
|
||||||
|
$"token c otp_code должен пройти: {(int)withOtp.StatusCode} {await withOtp.Content.ReadAsStringAsync()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disable_requires_code()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"tfa-dis-{Guid.NewGuid():N}");
|
||||||
|
var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
|
||||||
|
var sharedKey = (await enroll.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("sharedKey").GetString();
|
||||||
|
await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = GenerateTotp(sharedKey!) });
|
||||||
|
|
||||||
|
// Disable без code → 400.
|
||||||
|
using var bad = await api.Http.PostAsJsonAsync("/api/me/2fa/disable", new { code = (string?)null });
|
||||||
|
((int)bad.StatusCode).Should().Be(400);
|
||||||
|
|
||||||
|
// Disable с code → 200.
|
||||||
|
using var ok = await api.Http.PostAsJsonAsync("/api/me/2fa/disable", new { code = GenerateTotp(sharedKey!) });
|
||||||
|
ok.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
|
var status = await api.GetJsonAsync("/api/me/2fa/status");
|
||||||
|
status.GetProperty("enabled").GetBoolean().Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Generate RFC 6238 TOTP for given base32-encoded shared key.
|
||||||
|
/// 30-second time step, 6 digits, SHA1 (default for AuthenticatorTokenProvider).</summary>
|
||||||
|
private static string GenerateTotp(string base32Key)
|
||||||
|
{
|
||||||
|
var key = FromBase32(base32Key);
|
||||||
|
var counter = (long)Math.Floor((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30);
|
||||||
|
var counterBytes = BitConverter.GetBytes(counter);
|
||||||
|
if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes);
|
||||||
|
|
||||||
|
using var hmac = new HMACSHA1(key);
|
||||||
|
var hash = hmac.ComputeHash(counterBytes);
|
||||||
|
var offset = hash[^1] & 0x0F;
|
||||||
|
var binary = ((hash[offset] & 0x7F) << 24)
|
||||||
|
| ((hash[offset + 1] & 0xFF) << 16)
|
||||||
|
| ((hash[offset + 2] & 0xFF) << 8)
|
||||||
|
| (hash[offset + 3] & 0xFF);
|
||||||
|
var code = binary % 1_000_000;
|
||||||
|
return code.ToString("D6");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] FromBase32(string s)
|
||||||
|
{
|
||||||
|
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
s = s.Trim().TrimEnd('=').ToUpperInvariant();
|
||||||
|
var bytes = new List<byte>();
|
||||||
|
int buf = 0, bits = 0;
|
||||||
|
foreach (var c in s)
|
||||||
|
{
|
||||||
|
var v = alphabet.IndexOf(c);
|
||||||
|
if (v < 0) continue;
|
||||||
|
buf = (buf << 5) | v;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8)
|
||||||
|
{
|
||||||
|
bits -= 8;
|
||||||
|
bytes.Add((byte)((buf >> bits) & 0xFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue