.NET MAUI Authentication: ระบบ Login, JWT, SecureStorage และ Biometric ครบจบ

คู่มือสร้างระบบ Authentication ครบจบใน .NET MAUI 10 ตั้งแต่ Login, JWT, SecureStorage เก็บ token ปลอดภัย, Token Refresh อัตโนมัติ ไปจนถึง Biometric ลายนิ้วมือ/Face ID พร้อมโค้ดตัวอย่างที่ใช้งานได้จริง

ทำไมระบบ Authentication ในแอปมือถือถึงซับซ้อนกว่าเว็บ?

ถ้าคุณเคยทำระบบ login ในเว็บแอปมาก่อน อาจจะคิดว่า "ก็แค่ย้ายมาทำบนมือถือ ไม่น่าจะยากอะไร" แต่พอลงมือทำจริงๆ จะพบว่ามันซับซ้อนกว่าที่คิดเยอะเลยครับ เพราะแอปมือถือมีโจทย์หลายอย่างที่เว็บไม่เคยต้องกังวล

  • ไม่มี Cookie Jar อัตโนมัติ — ในเว็บ browser จัดการ cookie ให้หมด แต่ในแอปมือถือคุณต้องจัดการ token เองทั้งหมด
  • ผู้ใช้คาดหวังจะไม่ต้อง login บ่อยๆ — ต่างจากเว็บ ผู้ใช้มือถือหวังว่าเปิดแอปแล้วใช้ได้เลย ไม่ต้องมานั่งกรอก password ทุกวัน
  • ต้องรองรับ Biometric — ลายนิ้วมือ, Face ID กลายเป็นมาตรฐานไปแล้ว ผู้ใช้จะรู้สึกว่าแอปไม่ทันสมัยถ้าไม่มี
  • ความปลอดภัยของ Token — เก็บ token ผิดที่ อาจถูกขโมยได้ง่ายกว่าที่คิด
  • Token Refresh ต้องทำอัตโนมัติ — ถ้า access token หมดอายุระหว่างใช้งาน แอปต้อง refresh ได้โดยที่ผู้ใช้ไม่รู้สึกอะไรเลย

ผมเจอปัญหาพวกนี้มาเยอะจากโปรเจกต์จริง เลยอยากรวบรวมทุกอย่างไว้ที่เดียว ในบทความนี้จะพาคุณผ่านทุกขั้นตอนของการสร้างระบบ Authentication ใน .NET MAUI ตั้งแต่หน้า Login, เชื่อมต่อ API ด้วย JWT, เก็บ token ด้วย SecureStorage, refresh token อัตโนมัติ ไปจนถึง Biometric Authentication ทั้งหมดรองรับ .NET MAUI 10 (LTS) ปี 2026 ครับ

ภาพรวมสถาปัตยกรรม Authentication Flow

ก่อนจะลงมือเขียนโค้ด มาดู flow ทั้งหมดกันก่อนว่ามันทำงานยังไง:

  1. ผู้ใช้เปิดแอป → ตรวจสอบว่ามี token อยู่ใน SecureStorage หรือไม่
  2. มี token → เช็คว่ายังไม่หมดอายุ → ถ้ายังใช้ได้ ขอ Biometric ยืนยันตัวตนแล้วเข้าแอปได้เลย
  3. token หมดอายุ → ใช้ refresh token ขอ access token ใหม่อัตโนมัติ
  4. ไม่มี token → แสดงหน้า Login ให้กรอก username/password
  5. Login สำเร็จ → เก็บ access token + refresh token ใน SecureStorage
  6. ทุก HTTP request → DelegatingHandler แนบ Bearer token อัตโนมัติ
  7. ได้รับ 401 Unauthorized → DelegatingHandler refresh token อัตโนมัติแล้ว retry

flow นี้ครอบคลุมสถานการณ์จริงที่แอป production ต้องรับมือได้ทั้งหมด มาเริ่มสร้างกันทีละส่วนเลยครับ

ตั้งค่าโปรเจกต์และติดตั้ง NuGet Packages

เริ่มจากสร้างโปรเจกต์ใหม่แล้วลง packages ที่จำเป็น:

# สร้างโปรเจกต์ .NET MAUI
dotnet new maui -n SecureAuthApp

# ติดตั้ง packages ที่จำเป็น
cd SecureAuthApp
dotnet add package CommunityToolkit.Mvvm
dotnet add package Plugin.Maui.Biometric
dotnet add package System.IdentityModel.Tokens.Jwt

แต่ละ package ทำหน้าที่อะไรบ้าง:

  • CommunityToolkit.Mvvm — สำหรับ MVVM pattern ช่วยลดโค้ด boilerplate ได้เยอะมาก (ObservableObject, RelayCommand)
  • Plugin.Maui.Biometric — จัดการ Biometric Authentication ทั้งลายนิ้วมือและ Face ID รองรับ Android API 26+, iOS 15+, macOS 12+, Windows
  • System.IdentityModel.Tokens.Jwt — อ่านข้อมูลจาก JWT token ได้ เช่น decode claims หรือตรวจสอบ expiration

สร้าง Auth Models

ส่วนนี้ตรงไปตรงมา กำหนด models สำหรับ request/response ของ authentication API:

namespace SecureAuthApp.Models;

public class LoginRequest
{
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

public class AuthResponse
{
    public string AccessToken { get; set; } = string.Empty;
    public string RefreshToken { get; set; } = string.Empty;
    public int ExpiresIn { get; set; }
}

public class RefreshTokenRequest
{
    public string RefreshToken { get; set; } = string.Empty;
}

SecureStorage: เก็บ Token อย่างปลอดภัยบนทุกแพลตฟอร์ม

ส่วนนี้สำคัญมากครับ SecureStorage เป็น API ที่ .NET MAUI มีให้ในตัวเลย ไม่ต้องลง NuGet เพิ่ม ข้อดีคือมันเข้ารหัสข้อมูลอัตโนมัติด้วยกลไกเฉพาะของแต่ละแพลตฟอร์ม

กลไกเข้ารหัสแต่ละแพลตฟอร์ม

แพลตฟอร์ม กลไกเข้ารหัส รายละเอียด
Android EncryptedSharedPreferences AES-256 GCM — key เข้ารหัสแบบ deterministic สำหรับ lookup, value เข้ารหัสแบบ non-deterministic
iOS / macOS Keychain ใช้ SecRecord เก็บใน system Keychain — สำคัญ: ข้อมูลจะไม่ถูกลบแม้ uninstall แอป
Windows DataProtectionProvider Packaged apps เก็บใน LocalSettings, Unpackaged เก็บเป็น JSON ในไฟล์ securestorage.dat

สร้าง Token Service สำหรับจัดการ Token

using System.IdentityModel.Tokens.Jwt;

namespace SecureAuthApp.Services;

public interface ITokenService
{
    Task SaveTokensAsync(string accessToken, string refreshToken);
    Task<string?> GetAccessTokenAsync();
    Task<string?> GetRefreshTokenAsync();
    Task<bool> IsTokenValidAsync();
    Task ClearTokensAsync();
}

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

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

    public Task<string?> GetAccessTokenAsync()
        => SecureStorage.Default.GetAsync(AccessTokenKey);

    public Task<string?> GetRefreshTokenAsync()
        => SecureStorage.Default.GetAsync(RefreshTokenKey);

    public async Task<bool> IsTokenValidAsync()
    {
        var token = await GetAccessTokenAsync();
        if (string.IsNullOrEmpty(token))
            return false;

        try
        {
            var handler = new JwtSecurityTokenHandler();
            var jwtToken = handler.ReadJwtToken(token);
            // เช็คว่า token ยังไม่หมดอายุ พร้อม buffer 30 วินาที
            return jwtToken.ValidTo > DateTime.UtcNow.AddSeconds(30);
        }
        catch
        {
            return false;
        }
    }

    public Task ClearTokensAsync()
    {
        SecureStorage.Default.Remove(AccessTokenKey);
        SecureStorage.Default.Remove(RefreshTokenKey);
        return Task.CompletedTask;
    }
}

จุดที่น่าสนใจคือ buffer 30 วินาทีตอนเช็ค expiration ครับ ทำแบบนี้เพื่อป้องกันกรณีที่ token หมดอายุระหว่างทาง เช่น token เหลือ 2 วินาที เราดึงมาเช็คได้ แต่กว่าจะส่ง request ไปถึง server ก็หมดอายุไปแล้ว

จัดการ iOS Keychain Persistence หลัง Uninstall

นี่เป็นหลุมพรางที่หลายคนไม่รู้จนกว่าจะเจอปัญหาครับ บน iOS เมื่อผู้ใช้ลบแอปแล้วติดตั้งใหม่ ข้อมูลใน Keychain จะยังอยู่ ทำให้แอปอาจพยายามใช้ token เก่าที่ไม่ valid แล้ว วิธีแก้ง่ายมาก แค่เช็ค first-launch flag:

// ใน App.xaml.cs หรือ MauiProgram.cs
if (!Preferences.Default.ContainsKey("app_initialized"))
{
    SecureStorage.Default.RemoveAll();
    Preferences.Default.Set("app_initialized", true);
}

ใช้ได้ผลเพราะ Preferences (ซึ่งเก็บใน UserDefaults) จะถูกลบเมื่อ uninstall แต่ Keychain ไม่ถูกลบ เลยใช้เป็นตัวบอกว่า "นี่คือการติดตั้งใหม่" ได้

ยกเว้น SecureStorage จาก Android Auto Backup

อีกปัญหาหนึ่งบน Android ครับ API 23+ ระบบ Auto Backup อาจ backup ไฟล์ EncryptedSharedPreferences ขึ้น cloud แต่เมื่อ restore บนอุปกรณ์ใหม่จะ decrypt ไม่ได้ (เพราะ encryption key ผูกกับอุปกรณ์เดิม) ต้องยกเว้นไฟล์นี้ออก:

<!-- Platforms/Android/Resources/xml/auto_backup_rules.xml -->
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
    <include domain="sharedpref" path="." />
    <exclude domain="sharedpref"
        path="${applicationId}.microsoft.maui.essentials.preferences.xml" />
</full-backup-content>

แล้วอ้างอิงไฟล์นี้ใน AndroidManifest.xml:

<application android:fullBackupContent="@xml/auto_backup_rules">
    ...
</application>

สร้าง Auth Service สำหรับ Login และ Token Refresh

ทีนี้มาถึงส่วนที่เชื่อมต่อกับ backend API จริงๆ แล้ว service ตัวนี้ไม่ได้ซับซ้อนอะไร — หลักๆ คือ login แล้วเก็บ token, refresh token เมื่อหมดอายุ:

using System.Net.Http.Json;
using SecureAuthApp.Models;

namespace SecureAuthApp.Services;

public interface IAuthService
{
    Task<bool> LoginAsync(string username, string password);
    Task<bool> RefreshTokenAsync();
    Task LogoutAsync();
    Task<bool> IsAuthenticatedAsync();
}

public class AuthService : IAuthService
{
    private readonly HttpClient _httpClient;
    private readonly ITokenService _tokenService;

    public AuthService(
        IHttpClientFactory httpClientFactory,
        ITokenService tokenService)
    {
        // ใช้ named client ที่ไม่มี AuthHandler เพื่อป้องกัน circular dependency
        _httpClient = httpClientFactory.CreateClient("authClient");
        _tokenService = tokenService;
    }

    public async Task<bool> LoginAsync(string username, string password)
    {
        try
        {
            var request = new LoginRequest
            {
                Username = username,
                Password = password
            };

            var response = await _httpClient.PostAsJsonAsync(
                "/api/auth/login", request);

            if (!response.IsSuccessStatusCode)
                return false;

            var authResponse = await response.Content
                .ReadFromJsonAsync<AuthResponse>();

            if (authResponse is null)
                return false;

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

            return true;
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine(
                $"Login failed: {ex.Message}");
            return false;
        }
    }

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

            var request = new RefreshTokenRequest
            {
                RefreshToken = refreshToken
            };

            var response = await _httpClient.PostAsJsonAsync(
                "/api/auth/refresh", request);

            if (!response.IsSuccessStatusCode)
                return false;

            var authResponse = await response.Content
                .ReadFromJsonAsync<AuthResponse>();

            if (authResponse is null)
                return false;

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

            return true;
        }
        catch
        {
            return false;
        }
    }

    public async Task LogoutAsync()
    {
        await _tokenService.ClearTokensAsync();
    }

    public async Task<bool> IsAuthenticatedAsync()
    {
        return await _tokenService.IsTokenValidAsync();
    }
}

DelegatingHandler: แนบ JWT Token กับทุก Request อัตโนมัติ

ส่วนนี้เป็นหัวใจของระบบ auth เลยครับ ถ้าต้องเพิ่ม header เองทุกครั้งที่เรียก API มันจะลืมแน่นอน (เคยเจอมาแล้ว) ดังนั้นเราสร้าง DelegatingHandler ให้ทำอัตโนมัติ พร้อมรองรับ token refresh เมื่อได้รับ 401:

using System.Net;
using System.Net.Http.Headers;

namespace SecureAuthApp.Services;

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

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

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // แนบ access token กับ request
        var token = await _tokenService.GetAccessTokenAsync();
        if (!string.IsNullOrEmpty(token))
        {
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", token);
        }

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

        // ถ้าได้ 401 ลอง refresh token
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            await _refreshLock.WaitAsync(cancellationToken);
            try
            {
                // ตรวจสอบว่ามี thread อื่น refresh ไปแล้วหรือยัง
                var currentToken = await _tokenService.GetAccessTokenAsync();
                if (currentToken == token)
                {
                    // token ยังเป็นตัวเดิม → ต้อง refresh
                    var refreshed = await _authService.RefreshTokenAsync();
                    if (!refreshed)
                    {
                        // refresh ไม่สำเร็จ → ต้อง login ใหม่
                        await _tokenService.ClearTokensAsync();
                        await Shell.Current.GoToAsync("//login");
                        return response;
                    }
                }
            }
            finally
            {
                _refreshLock.Release();
            }

            // Retry ด้วย token ใหม่
            var newToken = await _tokenService.GetAccessTokenAsync();
            var retryRequest = await CloneRequestAsync(request);
            retryRequest.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", newToken);

            response = await base.SendAsync(retryRequest, cancellationToken);
        }

        return response;
    }

    private static async Task<HttpRequestMessage> CloneRequestAsync(
        HttpRequestMessage request)
    {
        var clone = new HttpRequestMessage(request.Method, request.RequestUri);
        if (request.Content != null)
        {
            var content = await request.Content.ReadAsByteArrayAsync();
            clone.Content = new ByteArrayContent(content);
            if (request.Content.Headers.ContentType != null)
                clone.Content.Headers.ContentType =
                    request.Content.Headers.ContentType;
        }

        foreach (var header in request.Headers)
            clone.Headers.TryAddWithoutValidation(header.Key, header.Value);

        return clone;
    }
}

มีจุดสำคัญที่ต้องสังเกตครับ:

  • SemaphoreSlim — ตรงนี้สำคัญมาก ลองนึกภาพว่า concurrent requests 5 ตัวได้ 401 พร้อมกัน ถ้าไม่มี lock ทุกตัวจะพยายาม refresh token พร้อมกัน ซึ่งอาจทำให้ refresh token ถูกใช้ซ้ำแล้ว invalidate
  • เปรียบเทียบ token ก่อน refresh — ถ้า token ถูกเปลี่ยนแล้ว (thread อื่น refresh ไปก่อน) ก็ไม่ต้อง refresh ซ้ำ เป็น double-check pattern ครับ
  • Clone request — HttpRequestMessage ส่งซ้ำไม่ได้ ต้อง clone ก่อน retry (อันนี้เป็นข้อจำกัดของ .NET เอง ไม่ใช่บั๊ก)

Biometric Authentication: เพิ่มลายนิ้วมือและ Face ID

มาถึงส่วนที่ผู้ใช้ชอบมากที่สุดแล้ว — ใช้ลายนิ้วมือหรือ Face ID แทนการกรอก password เราจะใช้ Plugin.Maui.Biometric ซึ่งรองรับทุกแพลตฟอร์มหลักเลยครับ

ตั้งค่า Platform-Specific

Android — เพิ่ม permission ใน AndroidManifest.xml:

<uses-permission android:name="android.permission.USE_BIOMETRIC" />

iOS — เพิ่มคำอธิบายใน Info.plist (บังคับสำหรับ Face ID ถ้าไม่ใส่แอปจะ crash ทันทีตอนเรียกใช้):

<key>NSFaceIDUsageDescription</key>
<string>แอปต้องการใช้ Face ID เพื่อยืนยันตัวตนสำหรับความปลอดภัย</string>

สร้าง Biometric Service

using Plugin.Maui.Biometric;

namespace SecureAuthApp.Services;

public interface IBiometricService
{
    Task<bool> IsBiometricAvailableAsync();
    Task<bool> AuthenticateAsync(string reason);
    Task<List<BiometricType>> GetAvailableTypesAsync();
}

public class BiometricService : IBiometricService
{
    private readonly IBiometric _biometric;

    public BiometricService(IBiometric biometric)
    {
        _biometric = biometric;
    }

    public async Task<bool> IsBiometricAvailableAsync()
    {
        var status = await _biometric.GetAuthenticationStatusAsync(
            AuthenticatorStrength.Strong);
        return status == BiometricHwStatus.Success;
    }

    public async Task<bool> AuthenticateAsync(string reason)
    {
        var request = new AuthenticationRequest
        {
            Title = "ยืนยันตัวตน",
            Subtitle = reason,
            Description = "ใช้ลายนิ้วมือหรือ Face ID เพื่อเข้าสู่ระบบ",
            NegativeText = "ยกเลิก",
            AllowPasswordAuth = true
        };

        var result = await _biometric.AuthenticateAsync(
            request, CancellationToken.None);

        return result.Status == BiometricResponseStatus.Success;
    }

    public async Task<List<BiometricType>> GetAvailableTypesAsync()
    {
        return await _biometric.GetEnrolledBiometricTypesAsync();
    }
}

สังเกตตรง AllowPasswordAuth = true ครับ — อันนี้เปิดทางเลือกให้ผู้ใช้กรอก PIN/password ของเครื่องได้ ในกรณีที่ biometric ใช้ไม่ได้หรือสแกนไม่ผ่าน ซึ่งเป็น best practice ที่ทั้ง Apple และ Google แนะนำเลย อย่าบังคับให้ผู้ใช้ใช้แต่ biometric อย่างเดียว

ลงทะเบียน Services ทั้งหมดใน MauiProgram.cs

ถึงเวลารวมทุกอย่างเข้าด้วยกันใน DI container แล้วครับ:

using Plugin.Maui.Biometric;
using SecureAuthApp.Services;
using SecureAuthApp.ViewModels;

namespace SecureAuthApp;

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

        // Services
        builder.Services.AddSingleton<ITokenService, TokenService>();
        builder.Services.AddSingleton<IBiometricService, BiometricService>();
        builder.Services.AddSingleton<IBiometric>(
            BiometricAuthenticationService.Default);

        // HTTP Clients
        builder.Services.AddTransient<AuthHeaderHandler>();

        // authClient — สำหรับ login/refresh (ไม่มี AuthHandler)
        builder.Services.AddHttpClient("authClient", client =>
        {
            client.BaseAddress = new Uri("https://your-api.com");
        });

        // apiClient — สำหรับ API calls ทั่วไป (มี AuthHandler)
        builder.Services.AddHttpClient("apiClient", client =>
        {
            client.BaseAddress = new Uri("https://your-api.com");
        })
        .AddHttpMessageHandler<AuthHeaderHandler>();

        // Auth Service (ต้องลงทะเบียนหลัง HttpClient)
        builder.Services.AddSingleton<IAuthService, AuthService>();

        // ViewModels
        builder.Services.AddTransient<LoginViewModel>();

        return builder.Build();
    }
}

ทำไมต้องมี HTTP client สองตัว? เพราะถ้า AuthService ใช้ client ที่มี AuthHeaderHandler แล้ว AuthHeaderHandler ก็ต้องใช้ AuthService — จะเกิด circular dependency ทันที การแยก client เป็นวิธีแก้ที่ง่ายและชัดเจนที่สุดครับ

  • authClient — ใช้สำหรับ login และ refresh token เท่านั้น ไม่ต้องมี AuthHeaderHandler เพราะตอน login ยังไม่มี token
  • apiClient — ใช้สำหรับเรียก API ทั่วไป มี AuthHeaderHandler คอยแนบ Bearer token ให้อัตโนมัติ

สร้างหน้า Login พร้อม Biometric

LoginViewModel

ViewModel ตัวนี้จัดการทั้ง login ด้วย username/password และ biometric ถ้าผู้ใช้เคย login สำเร็จมาก่อนและเครื่องรองรับ biometric มันจะเสนอให้ใช้ biometric login อัตโนมัติเลยตอนเปิดแอป:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using SecureAuthApp.Services;

namespace SecureAuthApp.ViewModels;

public partial class LoginViewModel : ObservableObject
{
    private readonly IAuthService _authService;
    private readonly IBiometricService _biometricService;
    private readonly ITokenService _tokenService;

    [ObservableProperty]
    private string _username = string.Empty;

    [ObservableProperty]
    private string _password = string.Empty;

    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    private string _errorMessage = string.Empty;

    [ObservableProperty]
    private bool _isBiometricAvailable;

    [ObservableProperty]
    private bool _hasExistingSession;

    public LoginViewModel(
        IAuthService authService,
        IBiometricService biometricService,
        ITokenService tokenService)
    {
        _authService = authService;
        _biometricService = biometricService;
        _tokenService = tokenService;
    }

    [RelayCommand]
    private async Task InitializeAsync()
    {
        IsBiometricAvailable =
            await _biometricService.IsBiometricAvailableAsync();

        // ตรวจสอบว่ามี refresh token อยู่ (เคย login สำเร็จมาก่อน)
        var refreshToken = await _tokenService.GetRefreshTokenAsync();
        HasExistingSession = !string.IsNullOrEmpty(refreshToken);

        // ถ้ามี session เก่าและ biometric พร้อมใช้ → ให้ biometric login อัตโนมัติ
        if (HasExistingSession && IsBiometricAvailable)
        {
            await BiometricLoginAsync();
        }
    }

    [RelayCommand]
    private async Task LoginAsync()
    {
        if (string.IsNullOrWhiteSpace(Username) ||
            string.IsNullOrWhiteSpace(Password))
        {
            ErrorMessage = "กรุณากรอกชื่อผู้ใช้และรหัสผ่าน";
            return;
        }

        IsBusy = true;
        ErrorMessage = string.Empty;

        try
        {
            var success = await _authService.LoginAsync(Username, Password);
            if (success)
            {
                await Shell.Current.GoToAsync("//main");
            }
            else
            {
                ErrorMessage = "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง";
            }
        }
        catch (HttpRequestException)
        {
            ErrorMessage = "ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้ กรุณาลองใหม่";
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand]
    private async Task BiometricLoginAsync()
    {
        IsBusy = true;
        ErrorMessage = string.Empty;

        try
        {
            var authenticated = await _biometricService.AuthenticateAsync(
                "ยืนยันตัวตนเพื่อเข้าสู่ระบบ");

            if (!authenticated)
            {
                ErrorMessage = "การยืนยันตัวตนล้มเหลว";
                return;
            }

            // Biometric ผ่าน → ตรวจสอบ token
            if (await _authService.IsAuthenticatedAsync())
            {
                await Shell.Current.GoToAsync("//main");
                return;
            }

            // token หมดอายุ → ลอง refresh
            var refreshed = await _authService.RefreshTokenAsync();
            if (refreshed)
            {
                await Shell.Current.GoToAsync("//main");
            }
            else
            {
                ErrorMessage = "Session หมดอายุ กรุณา login ใหม่";
                HasExistingSession = false;
            }
        }
        finally
        {
            IsBusy = false;
        }
    }
}

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:SecureAuthApp.ViewModels"
             x:Class="SecureAuthApp.Views.LoginPage"
             x:DataType="vm:LoginViewModel"
             Title="เข้าสู่ระบบ"
             Shell.NavBarIsVisible="False">

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

            <!-- Logo / App Name -->
            <Label Text="SecureAuth"
                   FontSize="32"
                   FontAttributes="Bold"
                   HorizontalOptions="Center" />

            <!-- Error Message -->
            <Label Text="{Binding ErrorMessage}"
                   TextColor="Red"
                   IsVisible="{Binding ErrorMessage,
                       Converter={StaticResource StringToBoolConverter}}"
                   HorizontalOptions="Center" />

            <!-- Username -->
            <Entry Placeholder="ชื่อผู้ใช้"
                   Text="{Binding Username}"
                   Keyboard="Plain"
                   ReturnType="Next" />

            <!-- Password -->
            <Entry Placeholder="รหัสผ่าน"
                   Text="{Binding Password}"
                   IsPassword="True"
                   ReturnType="Done" />

            <!-- Login Button -->
            <Button Text="เข้าสู่ระบบ"
                    Command="{Binding LoginCommand}"
                    IsEnabled="{Binding IsBusy,
                        Converter={StaticResource InvertedBoolConverter}}"
                    BackgroundColor="{StaticResource Primary}"
                    TextColor="White"
                    HeightRequest="50"
                    CornerRadius="10" />

            <!-- Biometric Login -->
            <Button Text="🔐 เข้าสู่ระบบด้วยลายนิ้วมือ / Face ID"
                    Command="{Binding BiometricLoginCommand}"
                    IsVisible="{Binding HasExistingSession}"
                    BackgroundColor="Transparent"
                    TextColor="{StaticResource Primary}"
                    BorderColor="{StaticResource Primary}"
                    BorderWidth="1"
                    HeightRequest="50"
                    CornerRadius="10" />

            <!-- Loading Indicator -->
            <ActivityIndicator IsRunning="{Binding IsBusy}"
                               IsVisible="{Binding IsBusy}"
                               HorizontalOptions="Center" />

        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

LoginPage.xaml.cs (Code-behind)

using SecureAuthApp.ViewModels;

namespace SecureAuthApp.Views;

public partial class LoginPage : ContentPage
{
    public LoginPage(LoginViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        if (BindingContext is LoginViewModel vm)
        {
            await vm.InitializeCommand.ExecuteAsync(null);
        }
    }
}

Code-behind สั้นมากครับ แค่ inject ViewModel แล้วเรียก Initialize ตอนหน้าเปิด ที่เหลือ logic อยู่ใน ViewModel ทั้งหมด

จัดการ Navigation: เช็ค Auth State ตอนเปิดแอป

ใน AppShell.xaml กำหนด route สำหรับ login และ main page:

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:SecureAuthApp.Views"
       x:Class="SecureAuthApp.AppShell">

    <ShellContent Route="login"
                  ContentTemplate="{DataTemplate views:LoginPage}" />

    <ShellContent Route="main"
                  ContentTemplate="{DataTemplate views:MainPage}" />
</Shell>

แล้วใน App.xaml.cs ตรวจสอบสถานะ authentication ตอนเปิดแอป เพื่อส่งผู้ใช้ไปหน้าที่ถูกต้อง:

namespace SecureAuthApp;

public partial class App : Application
{
    private readonly IAuthService _authService;

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

    protected override Window CreateWindow(IActivationState? state)
    {
        var window = new Window(new AppShell());

        window.Created += async (s, e) =>
        {
            if (await _authService.IsAuthenticatedAsync())
            {
                await Shell.Current.GoToAsync("//main");
            }
            else
            {
                await Shell.Current.GoToAsync("//login");
            }
        };

        return window;
    }
}

ความปลอดภัยเพิ่มเติมที่ควรทำ

ถ้าจะทำให้ระบบ auth แข็งแรงขึ้นอีก มีอีกหลายอย่างที่ควรพิจารณาครับ

1. ป้องกัน Screenshot และ Screen Recording

สำหรับหน้าที่มีข้อมูลสำคัญ (เช่น หน้าแสดงข้อมูลธนาคาร) ควรป้องกันไม่ให้ถ่ายภาพหน้าจอ:

// Android — ใน MainActivity.cs
#if ANDROID
Window.SetFlags(Android.Views.WindowManagerFlags.Secure);
#endif

บน iOS ทำได้ยากกว่า แต่สำหรับ Android แค่บรรทัดเดียวก็ป้องกันได้ทั้ง screenshot และ screen recording

2. ตรวจสอบ Root/Jailbreak

อุปกรณ์ที่ root หรือ jailbreak อาจถูกดักจับ token ได้ง่ายกว่า ควรแจ้งเตือนผู้ใช้อย่างน้อย (จะบล็อคไม่ให้ใช้เลยหรือแค่เตือนก็ขึ้นอยู่กับ policy ของแอป):

// ตัวอย่างเช็คเบื้องต้นสำหรับ Android
#if ANDROID
private bool IsDeviceRooted()
{
    string[] paths =
    {
        "/system/app/Superuser.apk",
        "/sbin/su",
        "/system/bin/su",
        "/system/xbin/su",
        "/data/local/xbin/su",
        "/data/local/bin/su",
        "/system/sd/xbin/su"
    };

    return paths.Any(File.Exists);
}
#endif

ต้องบอกตรงๆ ว่าวิธีนี้เป็นแค่การเช็คเบื้องต้นนะครับ ถ้าต้องการความแน่นอนจริงๆ ควรใช้ library เฉพาะทาง แต่สำหรับแอปทั่วไปก็เพียงพอแล้ว

3. Certificate Pinning

เพื่อป้องกัน man-in-the-middle attack ควรทำ certificate pinning สำหรับ API calls ด้วย:

// ใน MauiProgram.cs
builder.Services.AddHttpClient("apiClient", client =>
{
    client.BaseAddress = new Uri("https://your-api.com");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
    var handler = new HttpClientHandler();
    handler.ServerCertificateCustomValidationCallback =
        (message, cert, chain, errors) =>
    {
        if (cert is null) return false;

        // เปรียบเทียบ thumbprint กับค่าที่คาดหวัง
        var expectedThumbprint = "YOUR_CERT_THUMBPRINT_HERE";
        return cert.GetCertHashString()
            .Equals(expectedThumbprint,
                StringComparison.OrdinalIgnoreCase);
    };
    return handler;
})
.AddHttpMessageHandler<AuthHeaderHandler>();

ทดสอบระบบ Authentication

อย่าลืมเขียน test ด้วยนะครับ ถ้าระบบ auth มีบั๊ก ผู้ใช้จะถูกล็อคออกจากแอป ซึ่งเป็นเรื่องที่แย่มากในมุมของ user experience ตัวอย่าง unit test สำหรับ TokenService:

using Moq;
using SecureAuthApp.Services;

namespace SecureAuthApp.Tests;

public class TokenServiceTests
{
    [Fact]
    public async Task IsTokenValid_WithExpiredToken_ReturnsFalse()
    {
        // Arrange
        var service = new TokenService();
        // JWT token ที่หมดอายุแล้ว (exp ในอดีต)
        var expiredToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
            ".eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxNjAwMDAwMDAwfQ" +
            ".signature";

        await SecureStorage.Default.SetAsync("access_token", expiredToken);

        // Act
        var result = await service.IsTokenValidAsync();

        // Assert
        Assert.False(result);
    }

    [Fact]
    public async Task ClearTokens_RemovesAllTokens()
    {
        // Arrange
        var service = new TokenService();
        await service.SaveTokensAsync("access", "refresh");

        // Act
        await service.ClearTokensAsync();

        // Assert
        var accessToken = await service.GetAccessTokenAsync();
        var refreshToken = await service.GetRefreshTokenAsync();
        Assert.Null(accessToken);
        Assert.Null(refreshToken);
    }
}

Checklist สำหรับ Production

ก่อนปล่อยแอปขึ้น production ลองเช็ครายการนี้ให้ครบก่อนครับ จากประสบการณ์ส่วนตัว ลืมข้อใดข้อหนึ่งแล้วมันจะกลับมาหลอกหลอนในวันที่ไม่คาดคิด:

  • ใช้ HTTPS เท่านั้นสำหรับ API calls
  • เก็บ token ใน SecureStorage ไม่ใช่ Preferences
  • กำหนด auto backup rules สำหรับ Android
  • จัดการ iOS Keychain persistence หลัง uninstall
  • ใส่ NSFaceIDUsageDescription ใน Info.plist
  • ใช้ SemaphoreSlim ป้องกัน concurrent token refresh
  • Clone HttpRequestMessage ก่อน retry
  • มี fallback จาก biometric เป็น PIN/password
  • ทำ certificate pinning
  • ตั้ง access token expiration สั้น (15-30 นาที)
  • ตั้ง refresh token expiration ยาว (7-30 วัน)
  • ล้าง token เมื่อ logout

คำถามที่พบบ่อย (FAQ)

SecureStorage เก็บข้อมูลอย่างไรบนแต่ละแพลตฟอร์ม?

บน Android ใช้ EncryptedSharedPreferences เข้ารหัสด้วย AES-256 GCM บน iOS ใช้ System Keychain เข้ารหัสด้วย hardware security module ส่วน Windows ใช้ DataProtectionProvider ทุกแพลตฟอร์มเข้ารหัสอัตโนมัติโดยไม่ต้องตั้งค่าเพิ่มครับ

ถ้าผู้ใช้ลบแอปแล้วติดตั้งใหม่ ข้อมูลใน SecureStorage จะยังอยู่ไหม?

ขึ้นอยู่กับแพลตฟอร์มครับ บน Android ข้อมูลจะถูกลบเมื่อ uninstall (ยกเว้น Auto Backup เปิดอยู่ แต่ข้อมูลที่ restore กลับมาก็ decrypt ไม่ได้อยู่ดี) บน iOS ข้อมูล Keychain จะยังอยู่แม้ลบแอปแล้ว ดังนั้นต้องเช็ค first-launch flag แล้วเคลียร์ด้วย SecureStorage.Default.RemoveAll() ส่วน Windows packaged app ข้อมูลจะถูกลบไปพร้อมแอป

Plugin.Maui.Biometric รองรับแพลตฟอร์มใดบ้าง?

รองรับ iOS 15+, macOS 12+, Android API 26+ (Oreo ขึ้นไป) และ Windows 10.0.17763+ ครับ ทั้งลายนิ้วมือและ Face ID สามารถตั้ง AllowPasswordAuth = true เพื่อเปิดทางเลือก PIN/password ของเครื่องเป็น fallback ได้

วิธีที่ดีที่สุดในการแนบ JWT Token กับทุก HTTP Request คืออะไร?

ใช้ custom DelegatingHandler ลงทะเบียนผ่าน AddHttpMessageHandler<AuthHandler>() ใน MauiProgram.cs ครับ มันจะดักจับทุก outgoing request โดยอัตโนมัติ ดึง token จาก SecureStorage แล้วใส่ Authorization: Bearer header ให้เอง เมื่อได้ 401 Unauthorized ก็ refresh token อัตโนมัติด้วย SemaphoreSlim เพื่อป้องกัน race condition

WebAuthenticator ใน .NET MAUI รองรับ Windows ไหม?

ใน .NET MAUI 10 ที่เป็นเวอร์ชันปัจจุบัน WebAuthenticator ยังไม่รองรับ Windows โดยตรงครับ สำหรับ Windows ต้องใช้ WinUIEx library เป็น workaround โดยเรียก WinUIEx.WebAuthenticator.AuthenticateAsync() ร่วมกับ #if WINDOWS conditional compilation ส่วน Android, iOS และ macOS ใช้ WebAuthenticator ในตัวได้ตามปกติ

เกี่ยวกับผู้เขียน Editorial Team

Our team of expert writers and editors.