احراز هویت JWT در .NET MAUI: از SecureStorage تا رفرش خودکار توکن

راهنمای عملی پیاده‌سازی احراز هویت JWT در .NET MAUI — ذخیره امن توکن با SecureStorage، تزریق خودکار با DelegatingHandler، محافظت مسیرها با Shell Navigation Guard و رفرش خودکار با Polly. همراه با کد کامل و تست.

اگه سه مقاله قبلی ما رو دنبال کرده باشید، الان یه اپلیکیشن .NET MAUI دارید که معماری MVVM تمیزی داره، داده‌ها رو با SQLite لوکال ذخیره می‌کنه، و از طریق Refit و Polly به REST API وصل می‌شه. ولی یه تیکه مهم از پازل هنوز جا مونده: احراز هویت.

راستش، تقریباً هیچ API واقعی‌ای نیست که بدون احراز هویت کار بکنه. می‌خواید لیست وظایف شخصی رو نشون بدید؟ باید بدونید کاربر کیه. پروفایلش رو ویرایش کنه؟ بازم همون داستان. بدون احراز هویت، عملاً اپتون یه shell خالیه.

خب، توی این مقاله قراره دست به کار بشیم و یه سیستم احراز هویت کامل با JWT (JSON Web Token) بسازیم. از صفحه لاگین شروع می‌کنیم، توکن رو با SecureStorage امن ذخیره می‌کنیم، با DelegatingHandler اتوماتیک تزریقش می‌کنیم به درخواست‌های Refit، مسیرهای Shell رو محافظت می‌کنیم، و در نهایت با Polly رفرش خودکار توکن رو هم اضافه می‌کنیم. صبر کنید، بذارید از اول شروع کنیم.

پیش‌نیازها

  • .NET 10 SDK (نسخه 10.0.200 یا بالاتر)
  • Visual Studio 2022 v17.14+ یا Rider 2025.3+
  • آشنایی با معماری MVVM و CommunityToolkit.Mvvm (مقاله اول سری)
  • آشنایی با Refit و IHttpClientFactory (مقاله سوم سری)
  • یه REST API با endpoint‌های احراز هویت (یا از مثال‌های همین مقاله استفاده کنید)

پکیج‌های NuGet مورد نیاز:

dotnet add package CommunityToolkit.Mvvm --version 8.4.0
dotnet add package Refit.HttpClientFactory --version 8.0.0
dotnet add package Microsoft.Extensions.Http.Polly --version 10.0.0
dotnet add package System.IdentityModel.Tokens.Jwt --version 8.7.0

JWT چیست و چرا برای موبایل مناسبه؟

JSON Web Token (JWT) یه استاندارد باز (RFC 7519) برای انتقال امن اطلاعات بین دو طرف هست. ساختارش ساده‌ست: سه بخش داره — Header (الگوریتم رمزنگاری)، Payload (اطلاعات کاربر و زمان انقضا) و Signature (امضای دیجیتال).

ولی چرا JWT؟ مگه گزینه‌های دیگه‌ای نیست؟ هست، ولی JWT واقعاً برای موبایل حرف نداره:

  • Stateless: سرور نیازی به ذخیره session نداره — هر درخواست خودکفاست
  • سبک‌وزن: یه رشته متنی کوچیکه که راحت توی هدر HTTP جا می‌شه
  • چندپلتفرمی: فرقی نمی‌کنه اپ Android باشه یا iOS — JWT همه‌جا یکسان کار می‌کنه
  • امکان رفرش: با الگوی Access Token + Refresh Token، کاربر مجبور نیست مدام لاگین کنه

الگوی Access Token و Refresh Token

توی یه سیستم JWT استاندارد، دو نوع توکن داریم:

  • Access Token: عمر کوتاه (معمولاً ۱۵ تا ۶۰ دقیقه)، همراه هر درخواست API ارسال می‌شه
  • Refresh Token: عمر بلندتر (روزها یا هفته‌ها)، فقط برای گرفتن Access Token جدید استفاده می‌شه

منطقش ساده‌ست: وقتی Access Token منقضی بشه، به جای اینکه کاربر رو پرت کنیم به صفحه لاگین (که تجربه وحشتناکیه)، از Refresh Token استفاده می‌کنیم تا بی‌سر و صدا یه Access Token تازه بگیریم. کاربر اصلاً متوجه نمی‌شه.

مدل‌های احراز هویت

اول از همه، مدل‌هایی که نیاز داریم رو تعریف می‌کنیم. این بخش سرراسته:

// Models/Auth/LoginRequest.cs
public class LoginRequest
{
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

// Models/Auth/RegisterRequest.cs
public class RegisterRequest
{
    public string FullName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public string ConfirmPassword { get; set; } = string.Empty;
}

// Models/Auth/AuthResponse.cs
public class AuthResponse
{
    public string AccessToken { get; set; } = string.Empty;
    public string RefreshToken { get; set; } = string.Empty;
    public DateTime ExpiresAt { get; set; }
    public UserInfo User { get; set; } = new();
}

// Models/Auth/UserInfo.cs
public class UserInfo
{
    public int Id { get; set; }
    public string FullName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string AvatarUrl { get; set; } = string.Empty;
}

// Models/Auth/RefreshTokenRequest.cs
public class RefreshTokenRequest
{
    public string RefreshToken { get; set; } = string.Empty;
}

سرویس مدیریت توکن با SecureStorage

خب، اینجا می‌رسیم به یکی از مهم‌ترین تصمیمات امنیتی اپ: توکن‌ها رو کجا ذخیره کنیم؟

یه قانون طلایی: هرگز، تحت هیچ شرایطی، توکن رو توی Preferences یا یه فایل ساده ذخیره نکنید! .NET MAUI یه API عالی به اسم SecureStorage داره که روی هر پلتفرم از مکانیزم امنیتی بومی استفاده می‌کنه:

  • Android: EncryptedSharedPreferences (رمزنگاری AES-256 با Android Keystore)
  • iOS: Keychain (ذخیره‌سازی امن سیستم‌عامل)
  • Windows: DPAPI (Data Protection API)

بذارید اینترفیس و پیاده‌سازیش رو ببینیم:

// Services/Auth/ITokenService.cs
public interface ITokenService
{
    Task SaveTokensAsync(string accessToken, string refreshToken);
    Task<string?> GetAccessTokenAsync();
    Task<string?> GetRefreshTokenAsync();
    bool IsTokenExpired(string token);
    void ClearTokens();
}
// Services/Auth/TokenService.cs
using System.IdentityModel.Tokens.Jwt;

public class TokenService : ITokenService
{
    private readonly ISecureStorage _secureStorage;
    private const string AccessTokenKey = "access_token";
    private const string RefreshTokenKey = "refresh_token";

    public TokenService(ISecureStorage secureStorage)
    {
        _secureStorage = secureStorage;
    }

    public async Task SaveTokensAsync(string accessToken, string refreshToken)
    {
        await _secureStorage.SetAsync(AccessTokenKey, accessToken);
        await _secureStorage.SetAsync(RefreshTokenKey, refreshToken);
    }

    public Task<string?> GetAccessTokenAsync()
    {
        return _secureStorage.GetAsync(AccessTokenKey);
    }

    public Task<string?> GetRefreshTokenAsync()
    {
        return _secureStorage.GetAsync(RefreshTokenKey);
    }

    public bool IsTokenExpired(string token)
    {
        try
        {
            var handler = new JwtSecurityTokenHandler();
            var jwtToken = handler.ReadJwtToken(token);

            // ۳۰ ثانیه بافر قبل از انقضای واقعی
            return jwtToken.ValidTo <= DateTime.UtcNow.AddSeconds(30);
        }
        catch
        {
            return true;
        }
    }

    public void ClearTokens()
    {
        _secureStorage.Remove(AccessTokenKey);
        _secureStorage.Remove(RefreshTokenKey);
    }
}

یه نکته ظریف توی کد بالا هست که شاید از چشمتون دور بمونه: اون بافر ۳۰ ثانیه‌ای توی IsTokenExpired. چرا گذاشتیمش؟ فرض کنید توکن دقیقاً ۲ ثانیه دیگه منقضی می‌شه. درخواست رو می‌فرستید، توی مسیر شبکه ۳ ثانیه طول می‌کشه، تا به سرور برسه توکن expire شده. با این بافر، از قبل رفرش می‌کنیم و دیگه این مشکل پیش نمیاد.

تعریف API احراز هویت با Refit

حالا نوبت endpoint‌های احراز هویت رسیده. اگه مقاله قبلی درباره Refit رو خوندید، این بخش براتون آشنا و ساده‌ست:

// Services/Api/IAuthApi.cs
using Refit;

public interface IAuthApi
{
    [Post("/api/auth/login")]
    Task<AuthResponse> LoginAsync([Body] LoginRequest request);

    [Post("/api/auth/register")]
    Task<AuthResponse> RegisterAsync([Body] RegisterRequest request);

    [Post("/api/auth/refresh")]
    Task<AuthResponse> RefreshTokenAsync([Body] RefreshTokenRequest request);

    [Post("/api/auth/logout")]
    Task LogoutAsync();
}
// Services/Api/IProtectedApi.cs
using Refit;

public interface IProtectedApi
{
    [Get("/api/user/profile")]
    Task<UserInfo> GetProfileAsync();

    [Get("/api/todos")]
    Task<List<TodoItem>> GetTodosAsync();

    [Post("/api/todos")]
    Task<TodoItem> CreateTodoAsync([Body] TodoItem item);

    [Put("/api/todos/{id}")]
    Task<TodoItem> UpdateTodoAsync(int id, [Body] TodoItem item);

    [Delete("/api/todos/{id}")]
    Task DeleteTodoAsync(int id);
}

یه نکته مهم اینجاست: دو اینترفیس جدا تعریف کردیم. IAuthApi برای لاگین و ثبت‌نام (که هنوز توکنی وجود نداره) و IProtectedApi برای API‌هایی که باید توکن داشته باشن. این تفکیک بعداً خیلی به دردمون می‌خوره.

سرویس احراز هویت

الان وقتشه یه سرویس سطح بالا بسازیم که کل منطق احراز هویت رو مدیریت کنه. این سرویس در واقع هماهنگ‌کننده اصلی ماست:

// Services/Auth/IAuthService.cs
public interface IAuthService
{
    bool IsAuthenticated { get; }
    UserInfo? CurrentUser { get; }
    Task<(bool Success, string? Error)> LoginAsync(string email, string password);
    Task<(bool Success, string? Error)> RegisterAsync(RegisterRequest request);
    Task<bool> TryRefreshTokenAsync();
    Task LogoutAsync();
    Task<bool> CheckAuthenticationStateAsync();
}
// Services/Auth/AuthService.cs
using Refit;

public class AuthService : IAuthService
{
    private readonly IAuthApi _authApi;
    private readonly ITokenService _tokenService;

    public bool IsAuthenticated { get; private set; }
    public UserInfo? CurrentUser { get; private set; }

    public AuthService(IAuthApi authApi, ITokenService tokenService)
    {
        _authApi = authApi;
        _tokenService = tokenService;
    }

    public async Task<(bool Success, string? Error)> LoginAsync(
        string email, string password)
    {
        try
        {
            var response = await _authApi.LoginAsync(new LoginRequest
            {
                Email = email,
                Password = password
            });

            await _tokenService.SaveTokensAsync(
                response.AccessToken,
                response.RefreshToken);

            CurrentUser = response.User;
            IsAuthenticated = true;

            return (true, null);
        }
        catch (ApiException ex) when (ex.StatusCode ==
            System.Net.HttpStatusCode.Unauthorized)
        {
            return (false, "ایمیل یا رمز عبور اشتباه است");
        }
        catch (ApiException ex) when (ex.StatusCode ==
            System.Net.HttpStatusCode.BadRequest)
        {
            return (false, "اطلاعات وارد شده معتبر نیست");
        }
        catch (HttpRequestException)
        {
            return (false, "خطا در اتصال به سرور. اتصال اینترنت را بررسی کنید");
        }
    }

    public async Task<(bool Success, string? Error)> RegisterAsync(
        RegisterRequest request)
    {
        try
        {
            var response = await _authApi.RegisterAsync(request);

            await _tokenService.SaveTokensAsync(
                response.AccessToken,
                response.RefreshToken);

            CurrentUser = response.User;
            IsAuthenticated = true;

            return (true, null);
        }
        catch (ApiException ex) when (ex.StatusCode ==
            System.Net.HttpStatusCode.Conflict)
        {
            return (false, "این ایمیل قبلاً ثبت شده است");
        }
        catch (HttpRequestException)
        {
            return (false, "خطا در اتصال به سرور");
        }
    }

    public async Task<bool> TryRefreshTokenAsync()
    {
        try
        {
            var refreshToken = await _tokenService.GetRefreshTokenAsync();
            if (string.IsNullOrEmpty(refreshToken))
                return false;

            var response = await _authApi.RefreshTokenAsync(
                new RefreshTokenRequest { RefreshToken = refreshToken });

            await _tokenService.SaveTokensAsync(
                response.AccessToken,
                response.RefreshToken);

            CurrentUser = response.User;
            IsAuthenticated = true;

            return true;
        }
        catch
        {
            await LogoutAsync();
            return false;
        }
    }

    public async Task LogoutAsync()
    {
        try { await _authApi.LogoutAsync(); } catch { }
        _tokenService.ClearTokens();
        CurrentUser = null;
        IsAuthenticated = false;
    }

    public async Task<bool> CheckAuthenticationStateAsync()
    {
        var accessToken = await _tokenService.GetAccessTokenAsync();

        if (string.IsNullOrEmpty(accessToken))
        {
            IsAuthenticated = false;
            return false;
        }

        if (!_tokenService.IsTokenExpired(accessToken))
        {
            IsAuthenticated = true;
            return true;
        }

        return await TryRefreshTokenAsync();
    }
}

توجه ویژه به متد CheckAuthenticationStateAsync داشته باشید — این متد ستون فقرات فلوی احراز هویت ماست. هر بار که اپ باز می‌شه، اول چک می‌کنه آیا توکن معتبری داریم یا نه. اگه Access Token منقضی شده باشه، اتوماتیک با Refresh Token یکی جدید می‌گیره. کاربر هیچی از این فرآیند نمی‌فهمه و فقط وارد اپ می‌شه.

DelegatingHandler: تزریق خودکار توکن به درخواست‌ها

اینجا می‌رسیم به قلب تپنده سیستممون. صادقانه بگم، این بخش مورد علاقه من توی کل این مقاله‌ست.

ایده ساده‌ست: یه DelegatingHandler می‌سازیم که اتوماتیک توکن Bearer رو به تمام درخواست‌های HTTP اضافه کنه. نتیجه؟ دیگه لازم نیست توی هر ViewModel دستی هدر Authorization بذارید. کد تمیزتر، خطای کمتر.

// Handlers/AuthenticatedHttpClientHandler.cs
using System.Net;
using System.Net.Http.Headers;

public class AuthenticatedHttpClientHandler : DelegatingHandler
{
    private readonly ITokenService _tokenService;
    private readonly IAuthService _authService;
    private readonly SemaphoreSlim _refreshLock = new(1, 1);

    public AuthenticatedHttpClientHandler(
        ITokenService tokenService,
        IAuthService authService)
    {
        _tokenService = tokenService;
        _authService = authService;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // توکن فعلی رو بگیر و به هدر اضافه کن
        var token = await _tokenService.GetAccessTokenAsync();

        if (!string.IsNullOrEmpty(token))
        {
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", token);
        }

        var response = await base.SendAsync(request, cancellationToken);

        // اگه ۴۰۱ گرفتیم، سعی کن توکن رو رفرش کنی
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            await _refreshLock.WaitAsync(cancellationToken);
            try
            {
                // دوباره چک کن — شاید یه thread دیگه رفرش کرده باشه
                var currentToken = await _tokenService.GetAccessTokenAsync();
                if (currentToken != token)
                {
                    // توکن عوض شده، با توکن جدید دوباره امتحان کن
                    request.Headers.Authorization =
                        new AuthenticationHeaderValue("Bearer", currentToken);
                    return await base.SendAsync(request, cancellationToken);
                }

                // واقعاً نیاز به رفرش هست
                var refreshed = await _authService.TryRefreshTokenAsync();
                if (refreshed)
                {
                    var newToken = await _tokenService.GetAccessTokenAsync();
                    request.Headers.Authorization =
                        new AuthenticationHeaderValue("Bearer", newToken);
                    return await base.SendAsync(request, cancellationToken);
                }
            }
            finally
            {
                _refreshLock.Release();
            }
        }

        return response;
    }
}

حتماً SemaphoreSlim رو توی کد بالا دیدید. بذارید توضیح بدم چرا هست.

فرض کنید ۵ تا درخواست همزمان فرستاده بشه و همشون ۴۰۱ بگیرن. بدون این قفل، هر ۵ تا سعی می‌کنن همزمان توکن رو رفرش کنن — که هم بیهوده‌ست و هم ممکنه Refresh Token Rotation روی سرور رو خراب کنه. با SemaphoreSlim، فقط اولین درخواست رفرش انجام می‌ده و بقیه از توکن جدید بهره‌مند می‌شن. ساده و مؤثر.

پیکربندی کامل DI در MauiProgram.cs

خب، وقتشه همه قطعات پازل رو کنار هم بذاریم. بخش DI همیشه جایی‌ه که یه قدم عقب می‌ایستید و تصویر کلی رو می‌بینید:

// MauiProgram.cs
using Refit;
using Microsoft.Extensions.Http;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder.UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // === سرویس‌های پایه ===
        builder.Services.AddSingleton<ISecureStorage>(SecureStorage.Default);
        builder.Services.AddSingleton<ITokenService, TokenService>();
        builder.Services.AddSingleton<IAuthService, AuthService>();

        // === DelegatingHandler ===
        builder.Services.AddTransient<AuthenticatedHttpClientHandler>();

        // === Refit: کلاینت احراز هویت (بدون توکن) ===
        builder.Services
            .AddRefitClient<IAuthApi>(new RefitSettings
            {
                ContentSerializer = new SystemTextJsonContentSerializer(
                    new System.Text.Json.JsonSerializerOptions
                    {
                        PropertyNamingPolicy =
                            System.Text.Json.JsonNamingPolicy.CamelCase
                    })
            })
            .ConfigureHttpClient(c =>
            {
                c.BaseAddress = new Uri("https://api.yourapp.com");
                c.Timeout = TimeSpan.FromSeconds(30);
            });

        // === Refit: کلاینت محافظت‌شده (با توکن خودکار) ===
        builder.Services
            .AddRefitClient<IProtectedApi>(new RefitSettings
            {
                ContentSerializer = new SystemTextJsonContentSerializer(
                    new System.Text.Json.JsonSerializerOptions
                    {
                        PropertyNamingPolicy =
                            System.Text.Json.JsonNamingPolicy.CamelCase
                    })
            })
            .ConfigureHttpClient(c =>
            {
                c.BaseAddress = new Uri("https://api.yourapp.com");
                c.Timeout = TimeSpan.FromSeconds(30);
            })
            .AddHttpMessageHandler<AuthenticatedHttpClientHandler>();

        // === ثبت صفحات و ViewModelها ===
        builder.Services.AddTransient<LoginPage>();
        builder.Services.AddTransient<LoginViewModel>();
        builder.Services.AddTransient<RegisterPage>();
        builder.Services.AddTransient<RegisterViewModel>();
        builder.Services.AddTransient<ProfilePage>();
        builder.Services.AddTransient<ProfileViewModel>();

        return builder.Build();
    }
}

به یه نکته ظریف توجه کنید: IAuthApi بدون AddHttpMessageHandler ثبت شده ولی IProtectedApi با AuthenticatedHttpClientHandler. دلیلش اینه که لاگین و ثبت‌نام خودشون هنوز توکنی ندارن (منطقیه دیگه، هنوز لاگین نکردید!). این تفکیک یه مزیت دیگه هم داره: از مشکل Circular Dependency جلوگیری می‌کنه.

صفحه لاگین: ViewModel و View

بریم سراغ رابط کاربری. اول ViewModel لاگین رو با CommunityToolkit.Mvvm می‌سازیم:

// ViewModels/LoginViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class LoginViewModel : ObservableObject
{
    private readonly IAuthService _authService;

    public LoginViewModel(IAuthService authService)
    {
        _authService = authService;
    }

    [ObservableProperty]
    public partial string Email { get; set; } = string.Empty;

    [ObservableProperty]
    public partial string Password { get; set; } = string.Empty;

    [ObservableProperty]
    public partial string ErrorMessage { get; set; } = string.Empty;

    [ObservableProperty]
    public partial bool IsLoading { get; set; }

    [ObservableProperty]
    public partial bool HasError { get; set; }

    [RelayCommand]
    private async Task LoginAsync()
    {
        if (string.IsNullOrWhiteSpace(Email) ||
            string.IsNullOrWhiteSpace(Password))
        {
            ErrorMessage = "لطفاً ایمیل و رمز عبور را وارد کنید";
            HasError = true;
            return;
        }

        IsLoading = true;
        HasError = false;

        var (success, error) = await _authService.LoginAsync(Email, Password);

        IsLoading = false;

        if (success)
        {
            await Shell.Current.GoToAsync("//main");
        }
        else
        {
            ErrorMessage = error ?? "خطای ناشناخته";
            HasError = true;
        }
    }

    [RelayCommand]
    private async Task GoToRegisterAsync()
    {
        await Shell.Current.GoToAsync("register");
    }
}

و حالا صفحه XAML لاگین. چیز خاصی نداره، یه فرم ساده با binding به ViewModel:

<!-- Views/LoginPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:TaskManagerApp.ViewModels"
             x:Class="TaskManagerApp.Views.LoginPage"
             x:DataType="vm:LoginViewModel"
             Shell.NavBarIsVisible="False">

    <ScrollView>
        <VerticalStackLayout Padding="30" Spacing="20"
                             VerticalOptions="Center">

            <!-- لوگو و عنوان -->
            <Image Source="app_logo.png"
                   HeightRequest="80"
                   HorizontalOptions="Center" />

            <Label Text="به اپلیکیشن خوش آمدید"
                   FontSize="24"
                   FontAttributes="Bold"
                   HorizontalOptions="Center" />

            <!-- نمایش خطا -->
            <Frame BackgroundColor="#FDE8E8"
                   BorderColor="#F87171"
                   CornerRadius="8"
                   Padding="12"
                   IsVisible="{Binding HasError}">
                <Label Text="{Binding ErrorMessage}"
                       TextColor="#DC2626" />
            </Frame>

            <!-- فیلد ایمیل -->
            <Entry Placeholder="ایمیل"
                   Text="{Binding Email}"
                   Keyboard="Email"
                   ReturnType="Next" />

            <!-- فیلد رمز عبور -->
            <Entry Placeholder="رمز عبور"
                   Text="{Binding Password}"
                   IsPassword="True"
                   ReturnType="Done"
                   ReturnCommand="{Binding LoginCommand}" />

            <!-- دکمه ورود -->
            <Button Text="ورود"
                    Command="{Binding LoginCommand}"
                    IsEnabled="{Binding IsLoading, Converter=
                        {StaticResource InvertedBoolConverter}}"
                    BackgroundColor="#3B82F6"
                    TextColor="White"
                    CornerRadius="8"
                    HeightRequest="50" />

            <!-- نشانگر بارگذاری -->
            <ActivityIndicator IsRunning="{Binding IsLoading}"
                               IsVisible="{Binding IsLoading}"
                               Color="#3B82F6" />

            <!-- لینک ثبت‌نام -->
            <HorizontalStackLayout HorizontalOptions="Center"
                                    Spacing="5">
                <Label Text="حساب کاربری ندارید؟"
                       VerticalOptions="Center" />
                <Label Text="ثبت‌نام کنید"
                       TextColor="#3B82F6"
                       TextDecorations="Underline"
                       VerticalOptions="Center">
                    <Label.GestureRecognizers>
                        <TapGestureRecognizer
                            Command="{Binding GoToRegisterCommand}" />
                    </Label.GestureRecognizers>
                </Label>
            </HorizontalStackLayout>

        </VerticalStackLayout>
    </ScrollView>
</ContentPage>
// Views/LoginPage.xaml.cs
public partial class LoginPage : ContentPage
{
    public LoginPage(LoginViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

محافظت از مسیرها با Shell Navigation Guard

یکی از بخش‌هایی که خیلی‌ها فراموش می‌کنن (یا عقب می‌ندازنش) اینه که کاربر بدون لاگین نتونه به صفحات محافظت‌شده دسترسی پیدا کنه. خوشبختانه Shell یه ایونت Navigating داره که می‌تونیم ازش به عنوان Navigation Guard استفاده کنیم:

// AppShell.xaml.cs
public partial class AppShell : Shell
{
    private readonly IAuthService _authService;

    // مسیرهایی که بدون لاگین قابل دسترسی هستن
    private static readonly HashSet<string> PublicRoutes = new()
    {
        "login",
        "register",
        "forgot-password"
    };

    public AppShell(IAuthService authService)
    {
        _authService = authService;
        InitializeComponent();

        // ثبت مسیرها
        Routing.RegisterRoute("login", typeof(LoginPage));
        Routing.RegisterRoute("register", typeof(RegisterPage));
        Routing.RegisterRoute("profile", typeof(ProfilePage));

        this.Navigating += OnShellNavigating;
    }

    private async void OnShellNavigating(
        object? sender, ShellNavigatingEventArgs args)
    {
        var targetRoute = args.Target.Location
            .OriginalString.TrimStart('/');

        // مسیرهای عمومی نیاز به چک ندارن
        if (PublicRoutes.Any(r => targetRoute.Contains(r)))
            return;

        // یه deferral بگیر تا بتونیم async کار کنیم
        var deferral = args.GetDeferral();

        try
        {
            var isAuthenticated = await _authService
                .CheckAuthenticationStateAsync();

            if (!isAuthenticated)
            {
                args.Cancel();
                await GoToAsync("//login");
            }
        }
        finally
        {
            deferral.Complete();
        }
    }
}
<!-- AppShell.xaml -->
<?xml version="1.0" encoding="UTF-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:TaskManagerApp.Views"
       x:Class="TaskManagerApp.AppShell">

    <!-- مسیر لاگین -->
    <ShellContent Route="login"
                  ContentTemplate="{DataTemplate views:LoginPage}"
                  Shell.NavBarIsVisible="False"
                  Shell.TabBarIsVisible="False" />

    <!-- بخش اصلی اپ (بعد از لاگین) -->
    <TabBar Route="main">
        <ShellContent Route="home"
                      Title="خانه"
                      Icon="home.png"
                      ContentTemplate="{DataTemplate views:HomePage}" />
        <ShellContent Route="todos"
                      Title="وظایف"
                      Icon="tasks.png"
                      ContentTemplate="{DataTemplate views:TodosPage}" />
        <ShellContent Route="profile"
                      Title="پروفایل"
                      Icon="profile.png"
                      ContentTemplate="{DataTemplate views:ProfilePage}" />
    </TabBar>

</Shell>

یه هشدار مهم: حتماً deferral.Complete() رو توی بلاک finally صدا بزنید. اگه این کار رو نکنید و یه Exception رخ بده، ناوبری Shell برای همیشه قفل می‌شه و اپ عملاً فریز می‌کنه. من خودم یه بار این اشتباه رو کردم و نیم ساعت وقت صرف دیباگ کردم تا فهمیدم مشکل کجاست!

بررسی وضعیت احراز هویت هنگام شروع اپ

وقتی کاربر اپ رو باز می‌کنه، باید خودکار بررسی بشه که آیا قبلاً لاگین کرده یا نه. این بخش کوتاهه ولی حیاتیه:

// App.xaml.cs
public partial class App : Application
{
    private readonly IAuthService _authService;

    public App(IAuthService authService)
    {
        _authService = authService;
        InitializeComponent();
    }

    protected override Window CreateWindow(
        IActivationState? activationState)
    {
        var shell = new AppShell(
            Handler!.MauiContext!.Services
                .GetRequiredService<IAuthService>());

        var window = new Window(shell);

        window.Created += async (s, e) =>
        {
            var isAuth = await _authService
                .CheckAuthenticationStateAsync();

            await MainThread.InvokeOnMainThreadAsync(async () =>
            {
                if (isAuth)
                    await shell.GoToAsync("//main");
                else
                    await shell.GoToAsync("//login");
            });
        };

        return window;
    }
}

رفرش خودکار توکن با Polly

اگه مقاله قبلی درباره Polly رو خونده باشید، می‌دونید که چه ابزار قدرتمندی‌ه برای مدیریت خطاهای گذرا. اینجا یه لایه دیگه باهاش اضافه می‌کنیم: وقتی سرور ۴۰۱ برگردونه، اتوماتیک توکن رفرش بشه و درخواست دوباره ارسال بشه.

// Extensions/HttpClientBuilderExtensions.cs
using Polly;
using Polly.Extensions.Http;
using System.Net;

public static class HttpClientBuilderExtensions
{
    public static IHttpClientBuilder AddAuthRetryPolicy(
        this IHttpClientBuilder builder)
    {
        return builder.AddPolicyHandler((services, request) =>
        {
            var tokenService = services
                .GetRequiredService<ITokenService>();
            var authService = services
                .GetRequiredService<IAuthService>();

            return Policy<HttpResponseMessage>
                .HandleResult(r =>
                    r.StatusCode == HttpStatusCode.Unauthorized)
                .RetryAsync(1, async (outcome, retryCount, context) =>
                {
                    // سعی کن توکن رو رفرش کنی
                    await authService.TryRefreshTokenAsync();
                });
        });
    }
}

و توی MauiProgram.cs اضافه‌اش می‌کنید:

builder.Services
    .AddRefitClient<IProtectedApi>(/* ... */)
    .ConfigureHttpClient(/* ... */)
    .AddHttpMessageHandler<AuthenticatedHttpClientHandler>()
    .AddAuthRetryPolicy();  // اضافه کردن سیاست رفرش خودکار

با این ترکیب DelegatingHandler + Polly، کل فرآیند رفرش توکن کاملاً شفاف و اتوماتیکه. ViewModel‌ها اصلاً خبر ندارن که پشت صحنه چه اتفاقی داره میفته — فقط درخواست می‌فرستن و جواب می‌گیرن. و این دقیقاً همون چیزیه که یه معماری خوب باید باشه.

نکات امنیتی مهم

پیاده‌سازی احراز هویت فقط نوشتن کد نیست — اگه اصول امنیتی رو رعایت نکنید، بدتر از نداشتن احراز هویته. پس این بخش رو جدی بگیرید:

۱. هرگز توکن رو توی Preferences ذخیره نکنید

Preferences روی Android داده‌ها رو به صورت plaintext توی SharedPreferences ذخیره می‌کنه. هر اپ root-شده یا هر ابزار دیباگی می‌تونه بخونتش. همیشه از SecureStorage استفاده کنید. بدون استثنا.

۲. Certificate Pinning رو پیاده‌سازی کنید

برای جلوگیری از حملات Man-in-the-Middle (که توی شبکه‌های Wi-Fi عمومی خیلی رایجه)، Certificate Pinning رو فعال کنید:

<!-- Android: Resources/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">api.yourapp.com</domain>
        <pin-set expiration="2027-01-01">
            <pin digest="SHA-256">YOUR_PIN_HASH_HERE</pin>
        </pin-set>
    </domain-config>
</network-security-config>

۳. حداقل داده رو در توکن ذخیره کنید

یه نکته که خیلی‌ها یادشون میره: JWT رمزنگاری‌شده نیست — فقط امضا شده. یعنی هرکسی می‌تونه payload رو decode کنه و بخونه. پس اطلاعات حساس مثل شماره تلفن، آدرس یا اطلاعات مالی رو هرگز توی JWT نذارید.

۴. Refresh Token Rotation رو در سمت سرور فعال کنید

هر بار که Refresh Token استفاده می‌شه، سرور باید یه Refresh Token جدید برگردونه و قبلی رو باطل کنه. اینجوری حتی اگه یه Refresh Token به سرقت بره، عمر مفیدش خیلی کوتاهه.

۵. عمر توکن‌ها رو درست تنظیم کنید

  • Access Token: حداکثر ۱۵ دقیقه (کمتر بهتر)
  • Refresh Token: حداکثر ۷ تا ۳۰ روز (بسته به حساسیت اپ)

تست‌نویسی برای سرویس احراز هویت

بدون تست، هر تغییری ممکنه سیستم احراز هویت رو خراب کنه و شما هم متوجه نشید. بذارید با NSubstitute چند تست اساسی بنویسیم:

// Tests/AuthServiceTests.cs
using NSubstitute;
using Refit;
using System.Net;

public class AuthServiceTests
{
    private readonly IAuthApi _mockAuthApi;
    private readonly ITokenService _mockTokenService;
    private readonly AuthService _authService;

    public AuthServiceTests()
    {
        _mockAuthApi = Substitute.For<IAuthApi>();
        _mockTokenService = Substitute.For<ITokenService>();
        _authService = new AuthService(_mockAuthApi, _mockTokenService);
    }

    [Fact]
    public async Task LoginAsync_WithValidCredentials_ReturnsSuccess()
    {
        // Arrange
        var expectedResponse = new AuthResponse
        {
            AccessToken = "valid.jwt.token",
            RefreshToken = "refresh_token_123",
            User = new UserInfo
            {
                Id = 1,
                FullName = "تست کاربر",
                Email = "[email protected]"
            }
        };

        _mockAuthApi.LoginAsync(Arg.Any<LoginRequest>())
            .Returns(expectedResponse);

        // Act
        var (success, error) = await _authService
            .LoginAsync("[email protected]", "password123");

        // Assert
        Assert.True(success);
        Assert.Null(error);
        Assert.True(_authService.IsAuthenticated);
        Assert.Equal("تست کاربر", _authService.CurrentUser?.FullName);

        await _mockTokenService.Received(1)
            .SaveTokensAsync("valid.jwt.token", "refresh_token_123");
    }

    [Fact]
    public async Task LoginAsync_WithInvalidCredentials_ReturnsError()
    {
        // Arrange
        _mockAuthApi.LoginAsync(Arg.Any<LoginRequest>())
            .ThrowsAsync(await ApiException.Create(
                new HttpRequestMessage(),
                HttpMethod.Post,
                new HttpResponseMessage(HttpStatusCode.Unauthorized),
                new RefitSettings()));

        // Act
        var (success, error) = await _authService
            .LoginAsync("[email protected]", "wrong_password");

        // Assert
        Assert.False(success);
        Assert.NotNull(error);
        Assert.False(_authService.IsAuthenticated);
    }

    [Fact]
    public async Task TryRefreshTokenAsync_WithValidRefreshToken_ReturnsTrue()
    {
        // Arrange
        _mockTokenService.GetRefreshTokenAsync()
            .Returns("valid_refresh_token");

        _mockAuthApi.RefreshTokenAsync(Arg.Any<RefreshTokenRequest>())
            .Returns(new AuthResponse
            {
                AccessToken = "new.access.token",
                RefreshToken = "new_refresh_token",
                User = new UserInfo { Id = 1 }
            });

        // Act
        var result = await _authService.TryRefreshTokenAsync();

        // Assert
        Assert.True(result);
        Assert.True(_authService.IsAuthenticated);
    }

    [Fact]
    public async Task LogoutAsync_ClearsTokensAndState()
    {
        // Act
        await _authService.LogoutAsync();

        // Assert
        Assert.False(_authService.IsAuthenticated);
        Assert.Null(_authService.CurrentUser);
        _mockTokenService.Received(1).ClearTokens();
    }
}

سوالات متداول

آیا می‌تونم از Cookie Authentication به جای JWT استفاده کنم؟

از نظر فنی بله، ولی JWT برای اپ موبایل خیلی مناسب‌تره. Cookie‌ها وابسته به مرورگر و WebView هستن و مدیریتشون توی HttpClient بومی .NET MAUI پیچیده‌تره. JWT به هیچ زیرساخت خاصی وابسته نیست، با هر HttpClient کار می‌کنه، و حتی برای سناریوهای آفلاین هم بهتره — چون می‌تونید بدون اتصال به سرور اعتبار توکن رو بررسی کنید.

SecureStorage روی امولاتور Android امنه؟

جواب کوتاه: نه. روی امولاتور Android، ممکنه SecureStorage یه Exception بده یا به حالت Fallback بیفته. برای تست روی امولاتور از Preferences به عنوان جایگزین استفاده کنید، ولی حتماً تست نهایی رو روی دستگاه فیزیکی انجام بدید. همچنین قبل از انتشار مطمئن بشید که Android Keystore روی دستگاه هدف فعاله.

اگه Refresh Token هم منقضی بشه چی می‌شه؟

توی کدی که نوشتیم، وقتی TryRefreshTokenAsync شکست بخوره، متد LogoutAsync صدا زده می‌شه و توکن‌ها پاک می‌شن. بعدش Navigation Guard کاربر رو آروم و بی‌سر و صدا به صفحه لاگین هدایت می‌کنه. نه کرشی، نه خطای عجیبی. تجربه کاربری نرم و تمیز.

چطوری وضعیت لاگین رو بین بستن و باز کردن اپ حفظ کنم؟

دقیقاً همین کاری که توی CheckAuthenticationStateAsync انجام دادیم. توکن‌ها توی SecureStorage ذخیره می‌شن و بعد از باز شدن اپ، اول Access Token چک می‌شه. معتبره؟ مستقیم وارد اپ. منقضی شده؟ با Refresh Token یکی جدید می‌گیریم. هر دو منقضی شدن؟ صفحه لاگین. ساده و شفاف.

آیا DelegatingHandler با Blazor Hybrid هم کار می‌کنه؟

بله، صد در صد. DelegatingHandler بخشی از System.Net.Http هست و به فریمورک UI هیچ ربطی نداره. چه از .NET MAUI خالص استفاده کنید چه از Blazor Hybrid، همین الگوی DelegatingHandler + IHttpClientFactory + Refit بدون هیچ تغییری کار می‌کنه.

درباره نویسنده Editorial Team

Our team of expert writers and editors.