Как правильно “готовить” авторизацию в SPA?

190
28 августа 2018, 02:10

Цель такая: написать бэкенд ASP.Net Core MVC* SPA для работы с ReactJS и дальнейшей возможностью переиспользовать существующий API для создания, скажем, Android приложения.
Платформа: .Net Core 2.1.

* - Если такое ещё можно назвать MVC, учитывая, что View не будет, а будет отдельная директория ClientApp со всем содержимым фронтэнда.

Пошуршав интернеты, наткнулся на то, что ASP.Net Core Identity не актуален. Звучит логично, учитывая, что тот сильно опирается на куки, а при общении через API куки таскать неудобно. Хотя многие примеры нижеупомянутого JWT всё же используют IdentityUser.

Много инструкций с использованием JWT. Приличная часть из них слишком зациклена на фронтенд реализации и практически ничего не говорит о бэкенде. Не нашёл примеров с OAuth2, везде свой велосипед, причём Demo и не пригодный для реального использования.
В тех же примерах по JWT используются Issuer, Audience и SecretKey, но ни слова о том, по каким правилам их надо выбирать и/или генерировать, ну и где безопасно хранить (если исключить примеры с хардкодом, то их обычно хранили в appsettings.json).

Также в процессе гугления (конкретно: попытке найти инфу об JwtSecurityTokenHandler из System.IdentityModel.Tokens) MSDN Microsoft выдаёт:

We’re no longer updating this content regularly. Check the Microsoft Product Lifecycle for information about how this product, service, or technology is supported.

Что это значит? Microsoft более не поддерживает JWT? Технология в принципе уже неактуальна? Что же тогда использовать?

В итоге мне теперь не совсем понятно как строить авторизацию в своём приложении:

  • Нужные ли мне IdentityUser из Microsoft.AspNetCore.Identity?
  • Нужен ли мне IdentityDbContext из Microsoft.AspNetCore.Identity.EntityFrameworkCore?
  • Актуален ли JWT?
  • Как построить авторизацию с OAuth2?

Хотелось бы сохранить доступность авторизованного пользователя из HttpContext.User и к его Claims, чтобы не мучать БД лишний раз для получения Id/UserName/Avatar/LastOnline/etc.

А также учесть то, что активно будут использоваться роли пользователей.

UPD: Немного обновлю конечные цели, чтобы стало понятнее:

  • Это форум с разделами, постами и комментариями в формате вопрос-ответ
  • Реализация фронтенда через SPA
  • Планируется открытое API
  • Это же API будет использовать SPA
  • Поддержка быстрой регистрации/входа через сторонние сервисы (Vk, FB, Google, etc.)
Answer 1

Вот проверенный код, который работает. Итак, настройка аутентификации в Startup

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }
    public IConfiguration Configuration { get; }
    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
        services.AddDbContext<AppDbContext>(options =>                  
                options.UseMySql(Configuration.GetConnectionString("DefaultConnection")));
        services.AddIdentity<ApplicationUser, ApplicationRole>()
            .AddEntityFrameworkStores<AppDbContext>();          
        services.Configure<IdentityOptions>(options =>
        {
            //........
        });         

        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.RequireHttpsMetadata = false;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // укзывает, будет ли валидироваться издатель при валидации токена
                    ValidateIssuer = false, 
                    // будет ли валидироваться потребитель токена
                    ValidateAudience = false,                       
                    // будет ли валидироваться время существования
                    ValidateLifetime = true,
                    // установка ключа безопасности
                    IssuerSigningKey = AuthOptions.GetSymmetricSecurityKey(),
                    // валидация ключа безопасности
                    ValidateIssuerSigningKey = true,
                };
            });
        services.AddAuthorization(options =>
        {
            options.DefaultPolicy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
                .RequireAuthenticatedUser()
                .Build();
        });
        // .........            
    }
    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // ...............      
        app.UseAuthentication();
        // ................
    }
}

Класс AuthOptions

public class AuthOptions
{
    const string KEY = "mysupersecret_secretkey!";   // ключ для шифрации
    public const int LIFETIME = 300; // время жизни токена - 5 часов
    public static SymmetricSecurityKey GetSymmetricSecurityKey()
    {
        return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(KEY));
    }
}

Моделька для запроса токена

public class TokenRequest
{
    public string UserName { get; set; }
    public string Password { get; set; }
}

Контроллер для получения токена

[Route("api/[controller]")]
public class AccountController : Controller
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    public AccountController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
    {
        _userManager = userManager;
        _signInManager = signInManager;
    }
    [HttpPost("[action]")]
    public async Task<ActionResult> Auth([FromBody] TokenRequest tokenRequest)
    {
        var username = tokenRequest.UserName;
        var password = tokenRequest.Password;            
        var principal = await GetPrincipal(username, password);
        if (principal == null)
        {   
            return StatusCode(400, "Invalid username or password.");
        }
        var now = DateTime.UtcNow;
        // создаем JWT-токен
        var jwt = new JwtSecurityToken(                    
                notBefore: now,
                claims: principal.Claims,
                expires: now.Add(TimeSpan.FromMinutes(AuthOptions.LIFETIME)),
                signingCredentials: new SigningCredentials(AuthOptions.GetSymmetricSecurityKey(), SecurityAlgorithms.HmacSha256));
        var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);
        var response = new
        {
            token = encodedJwt,
            username = principal.Identity.Name
        };
        return Json(response);           
    }
    private async Task<ClaimsPrincipal> GetPrincipal(string username, string password)
    {
        var user = await _userManager.FindByNameAsync(username);
        if (user != null)
        {
            var check = await _userManager.CheckPasswordAsync(user, password);
            if (check)
            {
                var principal = await  _signInManager.CreateUserPrincipalAsync(user);
                return principal;                                        
            }
        }
        return null;
    }
}

И, по моему, это все. С этим все остальные вещи типа атрибутов авторизации, роли и прочее работает из коробки. Насколько я помню, ничего дополнительно делать не надо.

Answer 2

Для начала, ASP.Net Core Identity никаким образом не опирается на куки! ASP.Net Core Identity - это прежде всего система для хранения и обработки информации о пользователях.

Все что делает Identity во время аутентификации - это загружает из БД информацию о пользователе, заполняя набор утверждений о нем, после чего передает его ASP.NET Core.

Соответственно, нет никакого смысла искать как использовать ASP.Net Core Identity вместе с Jwt - это попросту не связанные друг с другом вещи.

Теперь про OAuth. Технология OAuth предназначена для того, чтобы сторонний разработчик мог создать приложение которое бы работало с вашим сайтом, но при этом не имел доступа к паролям ваших пользователей. Если же вы собрались создавать приложение на Андроиде самостоятельно - использование OAuth будет для вас избыточно!

Все что вам нужно - это action в контроллере, который принимает логин с паролем и выдает токен.

Ну а если вы решили дать доступ к своему API именно для сторонних разработчиков - Identity Server вам в помощь.

Наконец, про JWT.

Audience - это параметр токена, который означает целевую систему куда его можно предъявлять. Иными словами, это URL вашего сайта. Однако, нет никакого смысла в его использовании в ситуации когда у вас только один сайт: он нужен когда есть несколько сайтов со своими API и эти сайты не доверяют друг другу.

Issuer - это URI провайдера, который выдал JWT. То есть, опять-таки, URL вашего сайта. Это поле используется только в тех случаях когда доступ к API можно получить при помощи токенов от разных поставщиков. В остальных случаях это поле вообще-то не обязательное.

SecretKey - это, скорее всего, ключ, которым вы шифруете или подписываете JWT. Хранить его можно в appsettings.json или в переменных окружения. Главное - не публикуйте его на github. Вообще, желательно использовать разные ключи для разработки и на "боевом" сервере.

Answer 3

Отталкивался я от ответа @tym32167 (поэтому и пометил его как правильный), но на всякий случай добавлю конкретно свою реализацию. Вдруг будет кому-то полезна.

Startup.cs

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }
    public IConfiguration Configuration { get; }
    public void ConfigureServices(IServiceCollection services)
    {
        // подключение DbContext'ов
        // подключение Identity
        services.Configure<IdentityOptions>(options =>
        {
            // ...
        });
        // игнорирование регистра (url без заглавных букв)
        services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
        // подключение репозиториев
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.RequireHttpsMetadata = false;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = false,
                    //ValidIssuer = "",
                    ValidateAudience = false,
                    //ValidAudience = "",
                    ValidateLifetime = true,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Configuration["Jwt:Key"])),
                    ValidateIssuerSigningKey = true,
                };
            });
        services.AddAuthorization(options =>
        {
            options.DefaultPolicy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
                .RequireAuthenticatedUser()
                .Build();
        });
        services.AddMvc()
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
        services.AddSpaStaticFiles(configuration =>
        {
            configuration.RootPath = "ClientApp/build";
        });
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error"); //ToDo: do it better
            app.UseHsts();
        }
        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseSpaStaticFiles();
        app.UseAuthentication();
        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller}/{action=Index}/{id?}");
        });
        app.UseSpa(spa =>
        {
            spa.Options.SourcePath = "ClientApp";
            if (env.IsDevelopment())
            {
                // актуально для ReactJS
                spa.UseReactDevelopmentServer(npmScript: "start");
            }
        });
    }
}

appsettings.json

{
  "Jwt": {
    "Key": "my_very_secret_key",
    "LifeTime": "300"
  }
}

Jwt:Key должен быть больше 16 символов (128 бит) иначе выбрасывает исключение при генерации токена. По крайней мере, при использовании шифрования HmacSha256.

TokenRequest.cs

public class TokenRequest
{
    [Required(ErrorMessage = "")]
    [JsonProperty("login")]
    public string Login { get; set; }
    [Required(ErrorMessage = "")]
    [DataType(DataType.Password)]
    [JsonProperty("password")]
    public string Password { get; set; }
}

RegisterModel.cs

public class RegisterModel
{
    [Required(ErrorMessage = "")]
    [JsonProperty("username")]
    public string UserName { get; set; }
    [Required(ErrorMessage = "")]
    [EmailAddress]
    [DataType(DataType.EmailAddress)]
    [JsonProperty("email")]
    public string Email { get; set; }
    [Required(ErrorMessage = "")]
    [DataType(DataType.Password)]
    [JsonProperty("password")]
    public string Password { get; set; }
    [DataType(DataType.Password)]
    [Compare("Password", ErrorMessage = "")]
    [JsonProperty("confirm_password")]
    public string ConfirmPassword { get; set; }
}

AccountController.cs

[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    private readonly UserManager<UserAccount> userManager;
    private readonly SignInManager<UserAccount> signInManager;
    private readonly IConfiguration configuration;
    public AccountController(
        UserManager<UserAccount> userManager,
        SignInManager<UserAccount> signInManager,
        IConfiguration configuration)
    {
        this.userManager = userManager;
        this.signInManager = signInManager;
        this.configuration = configuration;
    }
    [HttpPost("[action]")]
    public async Task<IActionResult> Register([FromBody] RegisterModel model)
    {
        if (!ModelState.IsValid)
        {
            var errors = ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage));
            return BadRequest(errors);
        }
        var userAccount = new UserAccount { Email = model.Email, UserName = model.UserName };
        var result = await userManager.CreateAsync(userAccount, model.Password);
        if (result.Succeeded)
        {
            var token = await GetTokenAsync(userAccount);
            return Ok(new { token });
        }
        return BadRequest(result.Errors.Select(e => e.Description));
    }
    [HttpPost("[action]")]
    public async Task<IActionResult> Auth([FromBody] TokenRequest tokenRequest)
    {
        var userAccount = tokenRequest.Login.Contains('@') ?
            await userManager.FindByEmailAsync(tokenRequest.Login) :
            await userManager.FindByNameAsync(tokenRequest.Login);
        if (userAccount == null)
        {
            return NotFound(new { message = $"User with login {tokenRequest.Login} not found!" });
        }
        var passValided = await userManager.CheckPasswordAsync(userAccount, tokenRequest.Password);
        if (!passValided)
        {
            return UnprocessableEntity(new { message = "Invalid username or password." });
        }
        var token = await GetTokenAsync(userAccount);
        return Ok(token); 
    }
    private async Task<string> GetTokenAsync(UserAccount userAccount)
    {
        var principal = await signInManager.CreateUserPrincipalAsync(userAccount);
        var identity = (ClaimsIdentity)principal.Identity;
        if (identity == null)
        {
            return null;
        }
        var now = DateTime.UtcNow;
        // created JWT-token
        var jwt = new JwtSecurityToken(
                notBefore: DateTime.UtcNow,
                claims: identity.Claims,
                expires: now.Add(TimeSpan.FromMinutes(int.Parse(configuration["Jwt:LifeTime"]))),
                signingCredentials: new SigningCredentials(
                    new SymmetricSecurityKey(
                        Encoding.ASCII.GetBytes(configuration["Jwt:Key"])),
                    SecurityAlgorithms.HmacSha256));
        var token = new JwtSecurityTokenHandler().WriteToken(jwt);
        return token;
    }
}

UserAccount наследник IdentityUser, а UserRole наследник IdentityRole

READ ALSO
Сравнение даты, полученной из json

Сравнение даты, полученной из json

Сервер отдаёт дату вот в таком формате: 2015-04-24T07:00:51ZМне нужно узнать, не старше ли эта дата, например, трёх дней

214
Как отсортировать точки

Как отсортировать точки

Я хочу сделать так, чтобы можно было отсортировать двумерные точкиТо есть объекты, у которых есть 2 числовых значения

186
c# асинхронное выполнение методов

c# асинхронное выполнение методов

Мне нужно постоянно пинговать около 400 машин и при это м чтобы другой функционал программы работалПишу так :

190
Внутренний EventBus для AspNetCore 2.0

Внутренний EventBus для AspNetCore 2.0

Здравсвуйте, нужно реализовать внутрениий EventBus (не микросервисное взаимодействие на основе например RabbitMq)Все издатели пишут на общую шину...

172