Страницы

Поиск по вопросам

воскресенье, 8 декабря 2019 г.

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

#c_sharp #aspnet_core #aspnet_web_api #oauth #jwt


Цель такая: написать бэкенд 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.)

    


Ответы

Ответ 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(options => options.UseMySql(Configuration.GetConnectionString("DefaultConnection"))); services.AddIdentity() .AddEntityFrameworkStores(); services.Configure(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 _userManager; private readonly SignInManager _signInManager; public AccountController(UserManager userManager, SignInManager signInManager) { _userManager = userManager; _signInManager = signInManager; } [HttpPost("[action]")] public async Task 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 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; } } И, по моему, это все. С этим все остальные вещи типа атрибутов авторизации, роли и прочее работает из коробки. Насколько я помню, ничего дополнительно делать не надо.

Ответ 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. Вообще, желательно использовать разные ключи для разработки и на "боевом" сервере.

Ответ 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(options => { // ... }); // игнорирование регистра (url без заглавных букв) services.Configure(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 userManager; private readonly SignInManager signInManager; private readonly IConfiguration configuration; public AccountController( UserManager userManager, SignInManager signInManager, IConfiguration configuration) { this.userManager = userManager; this.signInManager = signInManager; this.configuration = configuration; } [HttpPost("[action]")] public async Task 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 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 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

Комментариев нет:

Отправить комментарий