Single Page Application : ASP.NET MVC .NET Core 2 + Angular 5. Часть 6.

В этой статье я продолжу улучшать тестовый проект. И по плану я буду делать следующие:

  • Изменю структуру проекта, сделаю его похожим на реальное приложение, с несколькими страницами, действиям пользователя, администрированием, авторизацией.
  • После реорганизации проекта, добавлю в него авторизацию на основе токенов OpenID/OAuth2 (используя ASOS)

Это достаточно много работы, несмотря на то, что это всего 2 пункта. Авторизация и аунтефикация обьемные темы. А реарганизация проекта снова вынуждает парвить файлы и добавлять реализацию в уже существующие. Поэтому это снова будет обьемная статья, так что будьте готовы 🙂

Новая структура проекта

Важная информация, перед тем как продолжить.

Я залил проект на GitHub. Достать исходники проекта можно отсюда: https://github.com/SIV-Blog/AngularSpa

Так же я разместил финальную версию проекта онлайн, можно пощелкать конечный результат тут: http://spa6.siv-blog.com

Работая над предыдущей статьей я понял, что проект выглядит слишком сырым, что бы хорошо отображать основные проблемы, решаемые при помощи Angular в реальном проекте. Если хорошо подумать, то основные проблемы, которые есть парктически в каждом реальном проекта можно уместить в такой список:

  • Загрузка основной (главной страницы)
  • Авторизация и аутенфикация пользователей, изменение и востановление пароля
  • Администрирование web ресурса (то, что могуть делатьтолько пользователи уровня Адинистратор)
  • Поддержка личного кабинета пользователя
  • Различное отображение страниц для авторизированых и не авторизированых пользователей
  • Логирование ошибоки и событий в системе
  • Ввод даных пользователем, отображение данных, фильтрация данных, удаление и обновленние данных
  • Печать различных отчетов
  • Рассылка email и SMS
  • Тут могу добавить любые пункты, которые вы мне пришлете 🙂

Что бы наглядно показать как это все можно реализовать при помощи ASP.NET MVC Core 2.0 и Angular 4 нужно расширить мой тестовый проект до чего то более существенного, чем одна страничка с тестовыми данными.
Поэтому я решил реализовать проект в виде простого онлайн магазина. Не важно какие товары мы будем продовать (компьютерные комплектующие, турпутевки или спорт питание), важна структура веб сайта. На таком примере будет достаточно легко показать все основные моменты работы с большим проектом.

Описание предметной области

Для начала я опишу нашу предметную область, нарисую структуру веб сайта, какие у нас будут обьекты в нашей модели, веб сраницы, переходы между ними. С этого и начнем.

Целью проекта является полноценый веб сервис Онлайн Магазин, используя который мы сможем продавать некие материальные вещи посетителям нашего сайта.

Основные разделы для нашего сайта будут:

  • Главная страница: показывает анонсы товаров, акции, новости, категории товаров
  • Страница с информацией для покупателей: информация о доставке, о владельцах, об оплате
  • Страница Категория. На ней показаны под категории. Например, Категория «Компьютерные комплектующие». У нее могут быть подкатегории «Процессор», «Память», и т.д.
  • Страница Подкатегории. На ней уже отображаются отфильтрованые товары.
  • Страница Товара. На ней отображается информация о товаре
  • Личный Кабинет. Тут хранится информация о зарегестрирвоаных пользователях (ФИО, адресс доставки, исторяи заказов)
  • Корзина. В ней происходит оформление закзазов.
  • Административная часть. Закрыта простым пользователям. Доступ только для администраторов и менеджеров магазина.
  • Административная страница. На ней отображается текущая информация: новые заказы, заказы в обработке, завершенные закзаы, пользователи, и т.д.
  • Страница Управление товарами. Тут можно добавлять и редактировать товары.
  • Страница Управление закзами. Тут можно добавлять и редактировать заказаы.
  • Страница Управление пользователями. Тут можно управлять зарегестрирвоаными пользователями.

Конечно-же у нас будет мастер страница с главным меню, формой обратной связи, панелью дял фильтрации продуктов и анонсами.

Я думаю что по мере выполнения проекта я буду помещать демо версии в онлайн, что бы их можно было поробывать.

Пока описание будет такое. Возможно по ходу работы я придумаю что то новое. Но и этого достаточно, что бы загрузитьс ебя работой на некоторое время 🙂

Обновление Angular 4 до Angular 5

1 ноября вышла новая версия Angular с номером 5. И я решил сразу же обновиться. Обещают более быструю работу, меньший размер и различные баг фиксы.

Открываем проект, открываем файл package.json и вносим такие изменения:

    "@angular/common": "~5.0.0",
    "@angular/compiler": "~5.0.0",
    "@angular/core": "~5.0.0",
    "@angular/forms": "~5.0.0",
    "@angular/http": "~5.0.0",
    "@angular/platform-browser": "~5.0.0",
    "@angular/platform-browser-dynamic": "~5.0.0",
    "@angular/router": "~5.0.0",
	
    "rxjs": "5.4.2",

Как видите, я просто поменял версии с 4 на 5. Теперь если скомпилировать проект, Angular файлы обновяться до последней версии.

Обновление Angular 4 до версии Angular 5

Обновление Angular 4 до версии Angular 5

Можете запустить проект и убедиться что все еще работает.

Обновление ASP.NET Core до версии 2.0.1

ASP.NET Core снова обновилось до версии 2.0.1. Что бы обновить проект нужно зайти в NuGet Package Manager, выбрать вкладку Updates и обновить все пакету которые там будут показаны.

Следующий шаг — привести в соотвествие с новой версией файлы Program.cs и Startup.cs

Откроем файл Program.cs и заменим весь класс Program на следующий:

    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }

Как видите, теперь большую часть стандартных настроек спрятали внутри нового класса WebHost.

Следующим исправим файл Startup.cs. Открываем его и первым делом меняем конструктор файла. Было:

        public Startup(IHostingEnvironment env)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
                .AddEnvironmentVariables();
            Configuration = builder.Build();
        }
		
		public IConfigurationRoot Configuration { get; }

Стало

        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
		
	public IConfiguration Configuration { get; }

Снова, основная часть настрое была спрятана внутри класса конфигурации и изменился интерфейс класса конфигурации. Т.е. код стал чище и короче.

Изменение структуры проекта

Теперь снова начнем менять проект. И самое первое что мы сделаем — обновим страницу загрузки. Сейчас, пок абраузер подгружает нужные JavaScript и CSS файлы мы видим надпись:

Loading AppComponent content here ...

Давайте поместим на страницу загрузки анимированый GIF loader, что бы скрасить этот неловкий момент ожидания. Я скачал GIF loader отсюда: https://icons8.com/preloaders/

Файл с именем main-loader.gif я поместил в папку wwwroot/images/

Затем на странице Index.cshtml я заменил содержимое. Было так:

<my-app>Loading AppComponent content here ...</my-app>

Стало вот так:

<my-app>
    <img src="~/images/main-loader.gif" class="main-loader" />
</my-app>

И что бы поместить его прямо в центр страницы, я добавил CSS в файл wwwroot/css/site.css

.main-loader {
    position: absolute;
    top: 50%;
    left: 50%;
    margin-top: -50px;
    margin-left: -50px;
    width: 64px;
    height: 64px;
}

Попутно буду удалять ненужные файлы. Для начала удалим банеры из wwwroot/images/. После удаления там должен остаться только main-loader.gif

Следующий шаг — создание главной страницы сайта. Основная разметк анаходится в файле /Views/Partial/AppComponent.cshtml. Я изменил HTML код что бы задать примитивную разметку страницы.

В верхней части будет заголовок с главным меню и ссылкой на корзину и личный кабинет.

На левой панели разместим меню с категориями товаров и обновляющиеся меня с подкатегориями для выбраной главной категории.

По центру будем отобржать главные категории товаров с картинками, названием и коротким описанием.

Справа разместим блоки для корзины, рекламы продвигаемых товаров и новостей.

Внизу будет простой футер.

Код такой разметки из файла /Views/Partial/AppComponent.cshtml


<div class="navbar navbar-inverse navbar-fixed-top">    
    <div class="container">
        <div class="navbar-header">
            <a routerLink="/home" routerLinkActive="active" class="navbar-brand">SPA Shop</a>
        </div>
        <div class="navbar-collapse collapse">
            <ul class="nav navbar-nav mr-auto">
                <li>
                    <a class="nav-link" (click)="setTitle('Главная :: SPA Shop')" routerLink="/home" routerLinkActive="active">Главная</a>
                </li>
                <li>
                    <a class="nav-link" (click)="setTitle('О магазине :: SPA Shop')" routerLink="/about">О магазине</a>
                </li>
                <li>
                    <a class="nav-link" (click)="setTitle('Контакты :: SPA Shop')" routerLink="/contact">Контакты</a>
                </li>
                <li>
                </li>
                <li>
                    <a class="nav-link" (click)="setTitle('Корзина :: SPA Shop')" routerLink="/cart">
                        <span class="glyphicon glyphicon-shopping-cart"></span>
                        <span class="text-white">Корзина</span>
                    </a>
                </li>
           </ul>

            
            <p class="navbar-text navbar-right" [hidden]="!isLoggedIn()">
                <a class="nav-link-text" (click)="setTitle('Личный кабинет :: SPA Shop')" routerLink="/profile">
                    <span class="glyphicon glyphicon-user"></span>
                    <span class="text-white">Личный кабинет</span>
                </a>
                <a class="nav-link-text" (click)="logout()" routerLink="/">
                    <span class="glyphicon glyphicon-log-out"></span>
                    <span class="text-white">Выход</span>
                </a>
            </p>

            <p class="navbar-text navbar-right" [hidden]="isLoggedIn()">
                Здравствуйте, Гость!
                <a class="nav-link-text" (click)="setTitle('Вход :: SPA Shop')" routerLink="/login">
                    <span class="glyphicon glyphicon-log-in"></span>
                    <span class="text-white">Войти</span>
                </a>
                <a class="nav-link-text" (click)="setTitle('Регистрация :: SPA Shop')" routerLink="/register">
                    <span class="glyphicon glyphicon-user"></span>
                    <span class="text-white">Регистрация</span>
                </a>
            </p>

        </div>
    </div>
</div>

<div id="wrap">
    <div class="container-fluid body-content">

        <router-outlet></router-outlet>

    </div>
</div>

<hr />
<footer class="footer">
    <ul>
        <li>
            <a class="nav-link" (click)="setTitle('Home - AngularSpa')" routerLink="/home" routerLinkActive="active">Главная</a>
        </li>
        <li>
            <a class="nav-link" (click)="setTitle('About - AngularSpa')" routerLink="/about">О магазине</a>
        </li>
        <li>
            <a class="nav-link" (click)="setTitle('Contact - AngularSpa')" routerLink="/contact">Контакты</a>
        </li>
    </ul>
    <p class="text-center">&copy; 2017 - SPA Shop. Разработано при помощи ASP.NET Core 2 и Angular 5</p>
</footer>

И соотвествующие новые CSS правила в файл wwwroot/css/site.css

body {
    padding-top: 50px;
    padding-bottom: 20px;
}

/* Wrapping element */
/* Set some basic padding to keep content from hitting the edges */
.body-content {
    padding-left: 15px;
    padding-right: 15px;
}

.main-loader {
    position: absolute;
    top: 50%;
    left: 50%;
    margin-top: -50px;
    margin-left: -50px;
    width: 64px;
    height: 64px;
}

html, body {
    height: 100%;
}

#wrap {
    min-height: 100%;
}

#main {
    overflow: auto;
    padding-bottom: 150px; /* this needs to be bigger than footer height*/
}

.footer {
    position: relative;
    margin-top: -150px; /* negative value of footer height */
    height: 150px;
    clear: both;
    padding-top: 20px;
    background-color: #d5d5d5;
}

.text-white {
    color: white;
}

.nav-link-text {
    color: #9d9d9d;
}

.nav-link-text:hover,
.nav-link-text:focus {
    color: #fff;
    background-color: transparent;
    text-decoration: none;
}

Пока это только заготовка на будущее. И в данный момент нас интересует ссылка Личный кабинет. Что бы войти в личный кабинет нужно авторизироваться в системе.
Для этого нам понадобяться несколько изменений в проект:

  • Страница с формой авторизации, на которой мы будем вводить Логин и Пароль
  • Соотвествующая подержка на сервере, что бы авторизировать пользователя

Начнем мы со второго пункта, и добавим поддержку авторизации на стороне сервера. Для работы с наше SPA мы будем использовать токены авторизации на основе OpenID Connect.

Для авторизации пользователь должен будет ввести логин и пароль. В ответ сервер пришлет 2 токена: токен доступа и токун обновления. Все последующие запросы к системе нужно будет производить включая в заголовок запроса токен доступа.

Так как токен доступа короткоживущий, нам нужно будет переодически обновлять его при помощи токена обновления.

Не все запросы к системе будуттребовать авторизации, что и логично. Просматривать каталог продуктов должен иметь возможность любой посетитель сайта. А вот при оформлении заказа система предложит зарегестрироваться, что бы сохранить заказ в историю и запомнить данные клиента.

Кроме того, пользователь сможет зарегестрироваться в системе щелкнув сылку Личный кабинет.

Настройка проекта для работы с токенами OpenID Connect

Для авторизации пользователя в системе я решил использовать собственный OpenID Connect сервер основанный на ASOS (сокращение от AspNet.Security.OpenIdConnect.Server).

В отличие от других проектов серверов идентификации, ASOS фокусируется только на части протокола OAuth2 / OpenID Connect и действует как тонкий слой между приложением и информацией о протоколе: у него нет функции членства, реализация страниц согласия (consent pages) остается на ваше усмотрение а добавление политики CORS выполнятся разработчиком в зависимости от его собственных потребностей. Т.е. это очень легковесный низкоуровневый сервер, в котором реализован атолько базовая функциональность.

Конечно, ASOS обрабатывает большинство данных протоколов самостоятельно (например, проверка запроса (request validation), обнаружение поставщиков (provider discovery), сериализация / десериализация маркеров), поэтому усилия, необходимые для реализации недостающих частей достаточно проста и ограниченна такими вещами как аутентификация клиентов или страницы формы согласия: вам не нужно быть экспертом по безопасности для использования ASOS. И это самое главное.

Небольшое отступление. Реализовать авторизацию пользователей можно различными методами. ASOS — лишь один из многиг. Например, есть OpenIddict — это совершенно новый сервер OpenID Connect, основанный на ASOS, предназначенный для более простого решения большинства сценариев аутентификации по токенам. Он поддерживает все потоки OAuth2 / OpenID Connect и включает встроенные процедуры проверки запросов. В качестве дополнительнйо работы остается только аутентификация и страница согласие пользователя, что можно сделать довольно просто, добавив в ваше приложение настраиваемый контроллер AuthorizationController, аналогично подходу, используемому Core Identity ASP.NET с его AccountController. Но снова, он завязан на ASP.NET Identity и Entity Framework. Это достаточно простое решение если вы используете ASP.NET Identity и Entity Framework, но подключить его к нестандартной БД будет непросто.

Я не ищу простых путей, поэтому решил делать реализацию через голый ASOS.

Для того что бы использовать ASOS нужно подключить пакет с OpenIdConnect сервер. Открываем NuGet Manager, переключаемся на вкладку Browse, вводим в поле поиска OpenIdConnect и отмечаем чекбокс Include prerelease. Смотрите на скриншот ниже.

Установка OpenId Connect

Установка OpenId Connect

Кроме того нам понадобиться пакет AspNet.Security.OAuth2.Validation. Этот ракет нужен, что бы проводить валидацию выпущенных OAuth2 токенов. Его так же нужно установить через менеджер пакетов NuGet.

Установка OpenId Connect

Установка OpenId Connect

Теперь нужно подключить сервер аутенфикации к проекту. Делаеться это в файле Startup.cs. Открываем его и добавляем следующий код в метод ConfigureServices:

	services.AddAuthentication()
		.AddOAuthValidation()
		.AddOpenIdConnectServer(options =>;
		{
			options.Provider = new AuthorizationProvider();

			options.AuthorizationEndpointPath = "/connect/authorize";
			options.TokenEndpointPath = "/connect/token";

			options.AllowInsecureHttp = true;
	
			options.ApplicationCanDisplayErrors = true;

			options.RefreshTokenLifetime = TimeSpan.FromDays(30);
		});
  • AddAuthentication — добавляет сервер аутентификации в список сервисов проекта
  • AddOAuthValidation — добавляет сервис валидации токенов OAuth2 в список сервисов проекта
  • AddOpenIdConnectServer — добавляет сервис OpenIdConnect сервера в список сервисов проекта и конфигурирует его
  • AuthorizationProvider — это класс, которы йбудет реализовать нашу авторизацию в преокте. Мы добавим его на следующем шаге
  • AuthorizationEndpointPath — это URL, который будет использоваться для авторизации на сервере при помощи страницы согласия (это когда авторизируешся при помощи Facebook например)
  • TokenEndpointPath — это URL, который будет использоваться для выдачи токенов доступа
  • AllowInsecureHttp — полезная опция, что бы выключить HTTPS во время разработки. В продакшене эту опцию нужно выключить
  • ApplicationCanDisplayErrors — разрешает показывать сообщения об ошибках на страницах авторизации
  • RefreshTokenLifetime — время жизни для токена обновления (refresh token)

Что бы включить футенфикацию/авторизацию в проекте (что бы запросы передавались на сервер аутенфикации во время прохождения кофеера ASP.NET Core) нужно добавить всего одну строчку в метод Configure:

	app.UseAuthentication();

С конфигурацией вес. теперь нам нужно реализвать класс AuthorizationProvider. Имено в нем мы будем решать кого авторизировать на нашем сервере и выпускать токены доступа и обновления.

Добавим папку Security и создадим внутри новый класс AuthorizationProvider. Он должен наследоваться от OpenIdConnectServerProvider. Этот базовый класс уже реализует практически все, что нам нужно. Мы просто переопределим парочку виртуальных функций, что бы настроить его на работу с нашим проектом.

using Microsoft.AspNetCore.Authentication;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

using WebApplication3.DbContext;
using AspNet.Security.OpenIdConnect.Server;
using AspNet.Security.OpenIdConnect.Primitives;
using System.Threading;

using AspNet.Security.OpenIdConnect.Extensions;

namespace WebApplication3
{
    public class AuthorizationProvider : OpenIdConnectServerProvider
    {
        public override Task ValidateTokenRequest(ValidateTokenRequestContext context)
        {
            // Reject the token request that don't use grant_type=password or grant_type=refresh_token.
            if (!context.Request.IsPasswordGrantType() && !context.Request.IsRefreshTokenGrantType())
            {
                context.Reject(
                    error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
                    description: "Only resource owner password credentials and refresh token " +
                                 "are accepted by this authorization server");

                return Task.CompletedTask;
            }

            // Since there's only one application and since it's a public client
            // (i.e a client that cannot keep its credentials private), call Skip()
            // to inform the server the request should be accepted without 
            // enforcing client authentication.
            context.Skip();

            return Task.CompletedTask;
        }

        public override async Task HandleTokenRequest(HandleTokenRequestContext context)
        {
            IUserManager userManager = context.HttpContext.RequestServices.GetService(typeof(IUserManager)) as IUserManager; 
            if(userManager == null)
                throw new Exception("User Manager object not configured.");

            // Only handle grant_type=password requests and let ASOS
            // process grant_type=refresh_token requests automatically.
            if (context.Request.IsPasswordGrantType())
            {
                DbContext.Entitites.User user = await userManager.FindByNameAsync(context.Request.Username);
                if (user == null)
                {
                    context.Reject(
                        error: OpenIdConnectConstants.Errors.InvalidGrant,
                        description: "Invalid credentials.");
                    return;
                }

                // Ensure the user is allowed to sign in.
                if (!await userManager.CanSignInAsync(user))
                {
                    context.Reject(
                        error: OpenIdConnectConstants.Errors.InvalidGrant,
                        description: "The specified user is not allowed to sign in.");
                    return;
                }


                // Ensure the user is not already locked out.
                if (userManager.SupportsUserLockout && await userManager.IsLockedOutAsync(user))
                {
                    context.Reject(
                        error: OpenIdConnectConstants.Errors.InvalidGrant,
                        description: "Invalid credentials.");
                    return;
                }

                // Ensure the password is valid.
                if (!await userManager.CheckPasswordAsync(user, context.Request.Password))
                {
                    // if entered wrong passwor d- we can lockout account
                    if (userManager.SupportsUserLockout)
                    {
                        await userManager.AccessFailedAsync(user);
                    }

                    context.Reject(
                        error: OpenIdConnectConstants.Errors.InvalidGrant,
                        description: "Invalid credentials.");

                    return;
                }

                if (userManager.SupportsUserLockout)
                { 
                    await userManager.ResetAccessFailedCountAsync(user);
                }

                var identity = new ClaimsIdentity(context.Scheme.Name,
                    OpenIdConnectConstants.Claims.Name,
                    OpenIdConnectConstants.Claims.Role);

                // Note: the subject claim is always included in both identity and
                // access tokens, even if an explicit destination is not specified.
                Claim c = new Claim(OpenIdConnectConstants.Claims.Subject, user.Id.ToString());
                identity.AddClaim(c);

                // Create a new authentication ticket holding the user identity.
                var ticket = new AuthenticationTicket(
                    new ClaimsPrincipal(identity),
                    new AuthenticationProperties(),
                    context.Scheme.Name);

                ticket.SetScopes(
                    OpenIdConnectConstants.Scopes.OpenId,
                    OpenIdConnectConstants.Scopes.OfflineAccess,
                    OpenIdConnectConstants.Scopes.Profile);

                context.Validate(ticket);       
            }

            return;
        }
    }
}

Я добавил коментарии в код, что бы было понятние как это работает. Но еще немного обьяснений не повредит.

Во-первых, в OpenId / OAuth2 есть несколько способов аутенфикации:

  • Resource owner password credentials flow
  • Client credentials grant
  • Interactive flows
  • Implicit flow

Каждый из этих типов аутенфикации должен использоваться в своем конкретном случае. Для SPA приложений лучше всего подходит Resource owner password credentials flow. Это когда пользователь вводит свой логин и пароль.

Client credentials grant — этот вариант лучше весго подходит для приложений, которые работают с вашим сервисом и установленны на другом сервере. Это связано с тем, что такое приложение — клиент должны хранить в секрете свои данные аутенфикации (секретный ключ и пароль к нему)

Interactive flows — это самый универсальный способ и подходит для любого типа приложения. Но н вовлекает в процесс аутенфикации переадресацию на страницу согласия пользователя. Это к примеру, когда вы авторизируетесь в мобильном приложении через Facebook, и для авторизации вам нужно нажать кнопочку Согласен на специальной странице Facebook. Вот это и есть страница согласия пользователя.

Implicit flow — этот тип аутенфикации так же практически универсальный, но он не подойдет для приложений развернутых на сервере.

В любом случае, наш вариант — это Resource owner password credentials flow.

Процесс выпуска токена состоит из двух этапов — валидации запроса и обработки запроса. Валидация запросо — это проверка, соотвествует ли запрос необходимым условиям для поддерживаемого типа аутенфикации. Это делается в методе ValidateTokenRequest.

Процесс обработки запроса происходит в методе HandleTokenRequest. именно тут мы проверяем логин и пароль, а затем создаем сруктуры, на сонове которых будет создан токен доступа и обновления.

Вопрос аутенфикации и авторизации очень большой. Я не хочу углубляться в эту тему прямо сейчас. ВО время развития проекта я буду добавлять новые функции для авторизации и буду стараться обьяснит как это работает. Но что бы понимать как работают токены Open Id Connect / OAuth2 стоит почитать дополнительные материалы.

Далее, что бы наш проект компилировался, нужно добавить прстранство имен в файл Startup.cs.

	using AngularSpa.Security;

Теперь проект можно скомпилировать.

Создаем базу данных для нового проекта

Так как цель проекта изменилась, то нам нужно обновить базу данных. Точнее нам нужно создать таблицы для все обектов, которые будут у нас в онлайн магазине. А именно:

  • Продукты (то, что мы будем продовать)
  • Категории продуктов
  • Заказы (кто и когда сделал заказ)
  • Детали заказов (продукты и их количество)
  • Склад
  • Архивные записи по складу (что бы следить за двиэением продуктов)
  • Новости магазина
  • Акции в магазине
  • Пользователи системы и покупатели

Все эти обьекты нужно представить в виде таблиц базы данных. Что бы не утомлять описанием как создавалась каждая таблица, я просто приведу SQL скрипт, который создает все обьекты. Вы можете скачать его тут: spa_db.zip.

Диаграмма базы данных выглядит следующим образом:

Структура новой БД

Структура новой БД

Теперь нужно добавить классы для представления каждой сущности в проекте SpaDatasource. Мы добавим новые файлы в папку Entitites:

  • Category.cs
  • Inventory.cs
  • InventoryLog.cs
  • News.cs
  • OrderDetails.cs
  • Product.cs
  • ProductCategoryMap.cs
  • Promotion.cs

Так же у нас уже есть 2 старых файла, которые мы так же изменим

  • Order.cs
  • User.cs

Каждый из этих файлов описывает свой класс. Это простые плоские типы C#. Я покажу их все в одном листинге для экономии места:

    public class Category
    {
        public int Id;
        public int ParentId;
        public string Name;
        public string Description;
        public string Photo;
        public string SeoName;
    }
	
	public class Inventory
    {
        public int ProductId;
        public int Quantity;
    }
	
    public class InventoryLog
    {
        public int Id;
        public int ProductId;
        public DateTime ActionDate;
        public string Action;
        public int Quantity;
        public int UserId;
    }

    public class News
    {
        public int Id;
        public string Title;
        public string Description;
        public DateTime CreateDate;
    }

    public class OrderDetails
    {
        public int Id;
        public int OrderId;
        public int ProductId;
        public int Quantity;
    }
	
    public class Order
    {
        public int Id;
        public int UserId;
        public string Description;
        public DateTime CreateDate;
        public DateTime PayDate;
        public bool IsShipped;
        public bool IsCanceled;
    }

    public class ProductCategoryMap
    {
        public int Id;
        public int ProductId;
        public int CategoryId;
    }

    public class Product
    {
        public int Id;
        public string Name;
        public string Description;
        public Decimal Price;
        public string Photo;
        public string SeoName;
        public bool IsActive;
    }

    public class Promotion
    {
        public int Id;
        public int ProductId;
        public DateTime StartDate;
        public DateTime EndDate;
    }

    public class User
    {
        public int Id;
        public string Login;
        public string PasswordHash;
        public string FullName;
        public string Country;
        public string City;
        public string Zip;
        public string Address;
        public string Phone;
        public string Email;
        public string Status;
    }	
	

Далее нам понадобятся функции для работы с этими обьектами. Для экономии места и времени я решил добавлять их когда они потребуються. Единственное что мы сделаем прямо сейчас — изменим функции которые работают с пользователями и заказами, чтобы включить все имеющиеся поля в БД в возвращаемые обьекты. Это достаточно просто.

Я приведу все измененные функции в одном листинге для экономии места. Все они находятся в файле SpaDatasource.cs проекта SpaDatasource

using Npgsql;
using NpgsqlTypes;
using SpaDatasource.Entitites;
using SpaDatasource.Interfaces;
using System;
using System.Collections.Generic;
using System.Text;

namespace SpaDatasource.Implementations
{
    public class SpaDatasource : ISpaDatasource, IDisposable
    {
        #region Атрибуты

        private bool _IsAlreadyDisposed = false;

        private string _ConnectionString;
        private NpgsqlConnection _Conn;

        #endregion

        #region Инициализация и освобождение ресурсов

        public SpaDatasource(string connString)
        {
            _ConnectionString = connString;
        }

        public void Dispose()
        {
            Dispose(true);  
            GC.SuppressFinalize(this);  
        }

        protected virtual void Dispose(bool isDisposing)
        {
            if (_IsAlreadyDisposed)
                return;

            if (isDisposing)
            {
                // освобождаем управляемые ресурсы тут
                Close();
            } 

            // если есть неуправляемые ресурсы - то их нужно совободить тут

            _IsAlreadyDisposed = true;
        }

        #endregion

        #region Открытие и закрытие соединения с БД

        public void Open()
        {
            if (_IsAlreadyDisposed)
                throw new ObjectDisposedException("MyDbContext", "Called [Open] method on disposed object.");

            if(_Conn != null)
            {
                if(_Conn.State != System.Data.ConnectionState.Closed && _Conn.State != System.Data.ConnectionState.Broken)
                    throw new InvalidOperationException("Called [Open] on already opened connection.");
            }

            _Conn = new NpgsqlConnection(_ConnectionString);
            _Conn.Open();
        }

        public void Close()
        {
            if(_Conn == null)
                return;

            if(_Conn.State == System.Data.ConnectionState.Closed || _Conn.State == System.Data.ConnectionState.Broken)
                return;

            _Conn.Close();
        }

        #endregion

        #region Работа с пользователями

        public User FindUserById(int id)
        {
            CheckConnValidity("FindUserById");

            string sql = "SELECT id, login, password_hash, full_name, country, city, zip, address, phone, email, status FROM users WHERE id = :id";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);
            command.Parameters.Add(new NpgsqlParameter("id", NpgsqlDbType.Integer));
            command.Parameters[0].Value = id;

            return GetUserFromCommand(command);
        }

        public User FindUserByName(string name)
        {
            CheckConnValidity("FindUserByName");

            string sql = "SELECT id, login, password_hash, full_name, country, city, zip, address, phone, email, status FROM users WHERE login = :login";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);
            command.Parameters.Add(new NpgsqlParameter("login", NpgsqlDbType.Text));
            command.Parameters[0].Value = name;

            return GetUserFromCommand(command);
        }

        public void InsertUser(User user)
        {
            CheckConnValidity("InsertUser");

            string sql = "INSERT INTO users (login, password_hash, full_name, country, city, zip, address, phone, email, status) VALUES (:login, :password_hash, :full_name, :country, :city, :zip, :address, :phone, :email, :status) RETURNING id";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            command.Parameters.Add(new NpgsqlParameter("login", NpgsqlDbType.Text));
            command.Parameters[0].Value = user.Login;

            command.Parameters.Add(new NpgsqlParameter("password_hash", NpgsqlDbType.Text));
            command.Parameters[1].Value = user.PasswordHash;

            command.Parameters.Add(new NpgsqlParameter("full_name", NpgsqlDbType.Text));
            command.Parameters[2].Value = user.FullName ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("country", NpgsqlDbType.Text));
            command.Parameters[3].Value = user.Country ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("city", NpgsqlDbType.Text));
            command.Parameters[4].Value = user.City ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("zip", NpgsqlDbType.Text));
            command.Parameters[5].Value = user.Zip ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("address", NpgsqlDbType.Text));
            command.Parameters[6].Value = user.Address ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("phone", NpgsqlDbType.Text));
            command.Parameters[7].Value = user.Phone ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("email", NpgsqlDbType.Text));
            command.Parameters[8].Value = user.Email ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("status", NpgsqlDbType.Text));
            command.Parameters[9].Value = user.Status ?? (object)DBNull.Value;

            int id = (int)command.ExecuteScalar();

            user.Id = id;
        }

        public void UpdateUser(User user)
        {
            CheckConnValidity("UpdateUser");

            string sql = "UPDATE users SET login = :login, password_hash = :password_hash, full_name = :full_name, country = :country, city = :city, zip = :zip, address = :address, phone = :phone, email = :email, status = :status";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            command.Parameters.Add(new NpgsqlParameter("id", NpgsqlDbType.Integer));
            command.Parameters[0].Value = user.Id;

            command.Parameters.Add(new NpgsqlParameter("login", NpgsqlDbType.Text));
            command.Parameters[0].Value = user.Login;

            command.Parameters.Add(new NpgsqlParameter("password_hash", NpgsqlDbType.Text));
            command.Parameters[1].Value = user.PasswordHash;

            command.Parameters.Add(new NpgsqlParameter("full_name", NpgsqlDbType.Text));
            command.Parameters[2].Value = user.FullName ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("country", NpgsqlDbType.Text));
            command.Parameters[3].Value = user.Country ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("city", NpgsqlDbType.Text));
            command.Parameters[4].Value = user.City ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("zip", NpgsqlDbType.Text));
            command.Parameters[5].Value = user.Zip ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("address", NpgsqlDbType.Text));
            command.Parameters[6].Value = user.Address ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("phone", NpgsqlDbType.Text));
            command.Parameters[7].Value = user.Phone ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("email", NpgsqlDbType.Text));
            command.Parameters[8].Value = user.Email ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("status", NpgsqlDbType.Text));
            command.Parameters[9].Value = user.Status ?? (object)DBNull.Value;

            command.ExecuteNonQuery();
        }

        public IEnumerable<User> Users()
        {
            CheckConnValidity("Users");

            List<User> list = new List<User>();

            string sql = "SELECT id, login, password_hash, full_name, country, city, zip, address, phone, email, status FROM users";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            NpgsqlDataReader dr = command.ExecuteReader();
            while(dr.Read())
            {
                User u = GetUserFromDataReader(dr);
                list.Add(u);
            }

            dr.Close();

            return list;
        }

        #endregion

        #region Работа с заказми

        public IEnumerable<Order> Orders()
        {
            CheckConnValidity("Orders");

            List<Order> list = new List<Order>();

            string sql = "SELECT id, user_id, description, create_date, pay_date, is_shipped, is_canceled FROM orders";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            NpgsqlDataReader dr = command.ExecuteReader();
            while(dr.Read())
            {
                Order u = GetOrderFromDataReader(dr);
                list.Add(u);
            }

            dr.Close();

            return list;
        }

        public IEnumerable<Order> FindOrdersForUser(int userId)
        {
            CheckConnValidity("FindOrdersForUser");

            List<Order> list = new List<Order>();

            string sql = "SELECT id, user_id, description, create_date, pay_date, is_shipped, is_canceled FROM orders WHERE user_id = :user_id";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            command.Parameters.Add(new NpgsqlParameter("user_id", NpgsqlDbType.Integer));
            command.Parameters[0].Value = userId;

            NpgsqlDataReader dr = command.ExecuteReader();
            while(dr.Read())
            {
                Order u = GetOrderFromDataReader(dr);
                list.Add(u);
            }

            dr.Close();

            return list;
        }

        public Order FindOrderById(int id)
        {
            CheckConnValidity("FindOrderById");

            string sql = "SELECT id, user_id, description, create_date, pay_date, is_shipped, is_canceled FROM orders WHERE id = :id";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            command.Parameters.Add(new NpgsqlParameter("id", NpgsqlDbType.Integer));
            command.Parameters[0].Value = id;

            return GetOrderFromCommand(command);
        }

        public void InsertOrder(Order order)
        {
            CheckConnValidity("InsertOrder");

            string sql = "INSERT INTO orders (user_id, description, create_date, pay_date, is_shipped, is_canceled) VALUES (:id, :user_id, :description, :create_date, :pay_date, :is_shipped, :is_canceled) RETURNING id";
            NpgsqlCommand command = CreateCommandWithTimeout(sql, _Conn);

            command.Parameters.Add(new NpgsqlParameter("user_id", NpgsqlDbType.Integer));
            command.Parameters[0].Value = order.UserId;

            command.Parameters.Add(new NpgsqlParameter("description", NpgsqlDbType.Text));
            command.Parameters[1].Value = order.Description ?? (object)DBNull.Value;

            command.Parameters.Add(new NpgsqlParameter("create_date", NpgsqlDbType.TimestampTZ));
            command.Parameters[2].Value = order.CreateDate;

            command.Parameters.Add(new NpgsqlParameter("pay_date", NpgsqlDbType.TimestampTZ));
            command.Parameters[3].Value = order.PayDate;

            command.Parameters.Add(new NpgsqlParameter("is_shipped", NpgsqlDbType.Boolean));
            command.Parameters[4].Value = order.IsShipped;

            command.Parameters.Add(new NpgsqlParameter("is_canceled", NpgsqlDbType.Boolean));
            command.Parameters[5].Value = order.IsCanceled;

            int id = (int)command.ExecuteScalar();

            order.Id = id;
        }

        #endregion

        #region Вспомогательные классы

        private void CheckConnValidity(string methodName)
        { 
            if (_IsAlreadyDisposed)
                throw new ObjectDisposedException("MyDbContext", "Called [" + methodName + "] method on disposed object.");

            if(_Conn == null)
                throw new InvalidOperationException("Called [" + methodName + "] method on null connection object.");

            if(_Conn.State == System.Data.ConnectionState.Closed) 
                throw new InvalidOperationException("Called [" + methodName + "] method on closed connection object.");

            if(_Conn.State == System.Data.ConnectionState.Broken)
                throw new InvalidOperationException("Called [" + methodName + "] method on broken connection object.");
        }

        private NpgsqlCommand CreateCommandWithTimeout(string cmdText, NpgsqlConnection conn, int Timeout = 30)
        {
            NpgsqlCommand command = new NpgsqlCommand(cmdText, conn)
            {
                CommandTimeout = Timeout
            };

            return command;
        }

        private User GetUserFromCommand(NpgsqlCommand command)
        {
            User user = null;

            NpgsqlDataReader dr = command.ExecuteReader();
            if (dr.Read())
            {
                user = GetUserFromDataReader(dr);
            }

            dr.Close();

            return user;
        }

        private User GetUserFromDataReader(NpgsqlDataReader dr)
        {
            return new User
            {
                Id              = dr.GetInt32(0),
                Login           = dr.GetString(1), 
                PasswordHash    = dr.GetString(2),
                FullName        = dr.IsDBNull(3) ? null : dr.GetString(3),
                Country         = dr.IsDBNull(4) ? null : dr.GetString(4),
                City            = dr.IsDBNull(4) ? null : dr.GetString(4),
                Zip             = dr.IsDBNull(5) ? null : dr.GetString(5),
                Address         = dr.IsDBNull(6) ? null : dr.GetString(6),
                Phone           = dr.IsDBNull(7) ? null : dr.GetString(7),
                Email           = dr.IsDBNull(8) ? null : dr.GetString(8),
                Status          = dr.IsDBNull(9) ? null : dr.GetString(9)
            };
        }

        private Order GetOrderFromCommand(NpgsqlCommand command)
        {
            Order order = null;

            NpgsqlDataReader dr = command.ExecuteReader();
            if (dr.Read())
            {
                order = GetOrderFromDataReader(dr);
            }

            dr.Close();

            return order;
        }

        private Order GetOrderFromDataReader(NpgsqlDataReader dr)
        {
            return new Order
                {
                    Id                  = dr.GetInt32(0),
                    UserId              = dr.GetInt32(1), 
                    Description         = dr.IsDBNull(2) ? "" : dr.GetString(2),
                    CreateDate          = dr.IsDBNull(3) ? DateTime.MinValue : dr.GetDateTime(3),
                    PayDate             = dr.IsDBNull(4) ? DateTime.MinValue : dr.GetDateTime(4),
                    IsShipped           = dr.GetBoolean(5),
                    IsCanceled          = dr.GetBoolean(6)
                };
        }

        #endregion

    }
}
		

После этих изменений перестал работать OrdersController. Так как мы будем его полностью менять, то не компилируйщийся код я просто удалю на данном этапе работы.

Просто удалите полностью файл OrdersController.cs.

Пока я хочу закончить с изменением кода доступа к данным и перейти к следующей.

Регистрация/Авторизация в Личном Кабинете

Следующий шаг — реализация формы авторизации и регистрации в личном кабинете.

Каждый новый пользовательдолжен иметоь возможность зарегестрироваться в системе. Каждый зарегестрированый пользователь должен името возможность войти в систему. Для этого нам нужно две формы: одна для регистрации и вторая для авторизации.

Изменим AppComponent.cshtml что бы отображать различные ссылкив зависимости от того, залогинился пользователь или нет.

Если пользователь не залогинился — будем показывать текст «Здравствуйте, Гость!» и ссылку на страницу логина и регистрации.

сли пользователь залогинился — будем показывать ссылку на страницу личного кабинета и выход из аккаунта.


	<p class="navbar-text navbar-right" [hidden]="!isLoggedIn()">
		<a class="nav-link-text" (click)="setTitle('Личный кабинет :: SPA Shop')" routerLink="/profile">
			<span class="glyphicon glyphicon-user"></span>
			<span class="text-white">Личный кабинет</span>
		</a>
		<a class="nav-link-text" (click)="logout()" routerLink="/">
			<span class="glyphicon glyphicon-log-out"></span>
			<span class="text-white">Выход</span>
		</a>
	</p>

	<p class="navbar-text navbar-right" [hidden]="isLoggedIn()">
		Здравствуйте, Гость!
		<a class="nav-link-text" (click)="setTitle('Вход :: SPA Shop')" routerLink="/login">
			<span class="glyphicon glyphicon-log-in"></span>
			<span class="text-white">Войти</span>
		</a>
		<a class="nav-link-text" (click)="setTitle('Регистрация :: SPA Shop')" routerLink="/register">
			<span class="glyphicon glyphicon-user"></span>
			<span class="text-white">Регистрация</span>
		</a>
	</p>
	

тут были добавленны новые пути роутера Angular:

  • /login
  • /register
  • /profile

Что бы это заработало, нужно сделатьс ледующие шаги:

  • Создаем файл компонента для каждого пути: login.component.ts, profile.component.ts, register.component.ts
  • Создаем соотвествующие методы в контролерре обработки частичных представлений PartialController: RegisterComponent, LoginComponent, ProfileComponent
  • Создаем частичныt представления: RegisterComponent.cshtml, LoginComponent.cshtml, ProfileComponent.cshtml
  • Реализуем компоненты Angular и задаем разметку в partial view файлах
  • Регестрируем новые компоненты и пути в файле app.routing.ts

Тут очень много изменений. Кроме добавления новых файлов я так же немного организовал файлы в проекте. Так что наверное в этом месте вам стоит обратиться к проекту Visual Studio для лучшего понимания, что изменилось и как.

Я постараюсь вкратце обьсянить все изменения.

Во первых все компоненты Angular я переместил в папку /wwwroot/app/components/. Все сервисы будут помещаться в папке /wwwroot/app/services/.

Cодержимое нового файла /wwwroot/app/components/register.component.ts

import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { Http } from '@angular/http';

import { AuthService } from '../security/auth.service';
import { RegisterViewModel } from '../models/RegisterViewModel';

@Component({
    selector: 'register',
    templateUrl: '/partial/registerComponent'
})

export class RegisterComponent
{
    registerViewModel: RegisterViewModel;
    errorMessage: string;

    constructor(public router: Router, private titleService: Title, public http: Http, private authService: AuthService) { }

    ngOnInit() {
        this.registerViewModel = new RegisterViewModel();
        this.errorMessage = null;
    }

    setTitle(newTitle: string) {
        this.titleService.setTitle(newTitle);
    }

    register(event: Event): void {
        event.preventDefault();
        let body = {
            'login': this.registerViewModel.login,
            'email': this.registerViewModel.email,
            'password': this.registerViewModel.password,
            'fullName': this.registerViewModel.fullName
        };

        this.http.post('/Account/Register', JSON.stringify(body), { headers: this.authService.jsonHeaders() })
            .subscribe(response => {
                if (response.status == 200)
                {
                    this.router.navigate(['/login']);
                }
                else
                {
                    console.log(response);
                    console.log(response.json());
                    this.errorMessage = response.json().messages[0];
                }
            },
            error =>
            {
                this.errorMessage = error.json().messages[0];
            });
    }
}

Этот компонент нужен для регистрации нового пользователя. Вся основная работа происходит в методе register. Именно тут мы считываем содержание формы и отправляем запрос на регисрацию нового пользователя используя URL /Account/Register.

Форма регистрации обьвялена в частичном представлении в /Views/Partial/RegisterComponent.cshtml

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*,AngularSpa"
@using AngularSpa.ViewModels
@model RegisterViewModel

<div class="jumbotron center-block register-form">
    <h2>Регистрация</h2>

    <div *ngIf="errorMessage != null">
        <div class="error-box">{{ errorMessage  }}</div>
    </div>

    <form role="form" #registerForm="ngForm">
        <div *ngIf="registerViewModel != null">
            <tag-di for="Login"></tag-di>
            <tag-di for="Password"></tag-di>
            <tag-di for="Email"></tag-di>
            <tag-di for="FullName"></tag-di>
            <button type="button" (click)="register($event)" class="btn btn-default">Зарегистрироваться</button>
            <span class="small">Уже с нами? <a routerLink="/login"> Входи тут!</a></span>
        </div>
    </form>
</div>

Для регисрации нового пользователя мы создадим новый контроллер AccountController. Он будет отвечать за регисрацию нового пользователя в системе.

Кроме того Angular компонент использует новый сервис AuthService. Его так же нужно добавить в проект.

Начнем с контроллера. Создайте новый контроллер AccountController в папке Api. Этот контролер будет содержать единственный метод register который будет вызываться на URL /Account/Register. В качестве входных данных он будет принимать информацию о новом пользовател в виде обьекта RegisterViewModel.

Код контроллера:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AngularSpa.ViewModels;
using AspNet.Security.OAuth.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SpaDatasource.Interfaces;

namespace AngularSpa.Api
{
    [Authorize(AuthenticationSchemes = OAuthValidationDefaults.AuthenticationScheme)]
    public class AccountController : Controller
    {
        [HttpPost]
        [AllowAnonymous]
        public async Task<IActionResult> Register([FromBody] RegisterViewModel newUser)
        {
            if (this.ModelState.IsValid)
            {
                IUserManager userManager = this.HttpContext.RequestServices.GetService(typeof(IUserManager)) as IUserManager;
                if (userManager == null)
                    throw new Exception("User Manager object not configured.");

                SpaDatasource.Entitites.User user = new SpaDatasource.Entitites.User
                {
                    Login = newUser.Login,
                    Email = newUser.Email,
                    FullName = newUser.FullName,
                    Status = SpaDatasource.SystemRoles.Customer
                };

                try
                {
                    SpaDatasource.Entitites.User u = await userManager.FindByNameAsync(newUser.Login);
                    if(u != null)
                    {
                        // alrady exists
                        ApiErrorResult apiResultExistingUser = new ApiErrorResult
                        {
                            Messages = new List<string> () { "Пользователь с таким именем уже существует." }
                        };

                        return new JsonResult(apiResultExistingUser) { StatusCode = 409};
                    }

                    await userManager.CreateAsync(user, newUser.Password);

                    return Ok();
                }
                catch(Exception ex)
                {
                    this.ModelState.AddModelError(string.Empty, ex.Message);
                }
            }

            ApiErrorResult apiResultError = new ApiErrorResult
            {
                Messages = ModelState.Keys.SelectMany(key => this.ModelState[key].Errors).Select(x => x.ErrorMessage).ToList<string>()
            };

            return new JsonResult(apiResultError) { StatusCode = 500 };
        }
    }
}

Этот контролер можно использовать без предварительной авторизации. Это нам позволяет аттрибут [AllowAnonymous]

Метод Register может быть вызван только в результате запроса HTTP POST, в теле которого должна содержаться информация о новом пользователе в виде JSON строки.

Метод проверяет, не нарушенны ли условия модели данных, затем проверяет есть ли в БД пользователь с таким же именем и если нет — то создает его.

В случае ошибки возращается JSON ответ с описанием ошибки.

Данные о пользователе передаются в обьекте RegisterViewModel. Его нужно создать. Создайте новый класс RegisterViewModel в папке ViewModels. Содержимое файла следующие:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.ViewModels
{
    public class RegisterViewModel
    {
        [Required]
        [StringLength(30, MinimumLength = 3)]
        [Display(Description = "Имя пользователя", Name = "Username", Prompt = "Введите имя пользователя")]
        public string Login { set; get; }

        [Required]
        [StringLength(100, MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Description = "Пароль", Name = "Password", Prompt = "Введите пароль для пользователя")]
        public string Password { set; get; }

        [Required, RegularExpression(@"([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})", ErrorMessage = "Please enter a valid email address.")]
        [EmailAddress]
        [Display(Description = "Email Адресс", Name = "EmailAddress", Prompt = "Email адресс")]
        [DataType(DataType.EmailAddress)]
        public string Email { get; set; }

        [StringLength(30, MinimumLength = 6)]
        [Display(Description = "Полное имя", Name = "FullName", Prompt = "Введите полное имя")]
        public string FullName { get; set; }
    }
}

Это класс, описывающий нашу модель и ее ограничения.

Далее создадим Angular сервис для авторизации пользователя. Созадйте новый файл /wwwroot/app/security/auth.service.ts

import { Component } from '@angular/core';
import { Injectable } from '@angular/core';
import { Headers } from '@angular/http';
import { OpenIdToken } from './OpenIdToken';


@Injectable()
export class AuthService {

    constructor() { }

    // for requesting secure data using json
    authJsonHeaders() {
        let header = new Headers();
        header.append('Content-Type', 'application/json');
        header.append('Accept', 'application/json');
        header.append('Authorization', 'Bearer ' + localStorage.getItem('access_token'));
        return header;
    }

    // for requesting secure data from a form post
    authFormHeaders() {
        let header = new Headers();
        header.append('Content-Type', 'application/x-www-form-urlencoded');
        header.append('Accept', 'application/json');
        header.append('Authorization', 'Bearer ' + localStorage.getItem('access_token'));
        return header;
    }

    // for requesting unsecured data using json
    jsonHeaders() {
        let header = new Headers();
        header.append('Content-Type', 'application/json');
        header.append('Accept', 'application/json');
        return header;
    }

    // for requesting unsecured data using form post
    contentHeaders() {
        let header = new Headers();
        header.append('Content-Type', 'application/x-www-form-urlencoded');
        header.append('Accept', 'application/json');
        return header;
    }

    // After a successful login, save token data into session storage
    login(responseData: OpenIdToken)
    {
        let access_token: string = responseData.access_token;
        let refresh_token: string = responseData.refresh_token;
        let expires_in: number = responseData.expires_in;


        let now = new Date();
        now.setSeconds(now.getSeconds() + expires_in);

        localStorage.setItem('access_token', access_token);
        localStorage.setItem('refresh_token', refresh_token);
        localStorage.setItem('expires_in', now.toString());
    }

    // called when logging out user; clears tokens from localStorage
    logout()
    {
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        localStorage.removeItem('expires_in');
    }

    isLoggedIn()
    {
        let at = localStorage.getItem('access_token');
        if (at === null)
            return false;

        let expireStr = localStorage.getItem('expires_in');
        if (expireStr === null)
            return false;

        let expire = new Date(expireStr);
        let now = new Date();
        if (now >= expire)
            return false;

        return true;
    }
}

Этот сервис позволяет созранить токен доступа, полученный от сервера в локальном хранилище и использовать его для посылки авторизированых запросов. Для этого нужно вызвать метод authJsonHeaders() или authFormHeaders() и использовать полученный результат в качестве headers для HTTP Запроса. Мы будем делать это позже.

Метод logout() удаляет токены доступа из локального хранилища, тем самым делая невозможным дальнейшие запросы к защищеным ресурсам.

Функция isLoggedIn() проверяет если в системе есть валидный токен доступа, что означает что текущий пользователь авторизировался в системе.

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

Снова, этот сервис использует обьект OpenIdToken, так что нужно добавить его в систему. Создайте новый файл /wwwroot/app/security/OpenIdToken.ts

import { Component } from '@angular/core';

export class OpenIdToken {
    scope: string;
    token_type: string;
    access_token: string;
    expires_in: number;
    refresh_token: string;
    id_token: string;
}

Этот класс просто хранит информацию о токене доступа и токене обновления полученными от сервера авторизации.

Следующий компонент нужен для входа в систему (login). Создадим новый файл компонента /wwwroot/app/components/login.component.ts

Содержимое нового файла /wwwroot/app/components/login.component.ts

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';

import { Http } from '@angular/http';
import { AuthService } from '../security/auth.service';
import { LoginViewModel } from '../models/LoginViewModel';

@Component({
    selector: 'login',
    templateUrl: '/partial/loginComponent'
})

export class LoginComponent {
    loginViewModel: LoginViewModel;
    errorMessage: string;

    constructor(public router: Router, private titleService: Title, public http: Http, private authService: AuthService) { }

    ngOnInit() {
        this.loginViewModel = new LoginViewModel();
        this.errorMessage = null;
    }

    public setTitle(newTitle: string) {
        this.titleService.setTitle(newTitle);
    }

    // post the user's login details to server, if authenticated token is returned, then token is saved to session storage
    onLoginClick(event: Event): void {
        event.preventDefault();
        let body = 'username=' + this.loginViewModel.login + '&password=' + this.loginViewModel.password + '&grant_type=password&scope=openid offline_access';

        this.http.post('/connect/token', body, { headers: this.authService.contentHeaders() })
            .subscribe(response =>
            {
                // success, save the token
                this.errorMessage = null;
                this.authService.login(response.json());
                this.router.navigate(['/']);
            },
            error =>
            {
                // failed
                this.errorMessage = "Запрос не был обработан. Что-то нехорошее с сервером.";
            }
            );
    }
}

При инициализации этого компонента мы просто создаем пустую модель, что бы показать пустую форму входа в систему. Затем, когда пользователь нажимает кнопку, вызываем функцию onLoginClick.
Эта функция отслылает веденные данные на сервер, по адрессу /connect/token. Этот адрес — специальный URL, который мы сконфигурировали, когда подключали сервер авторизации. Запрос на данный URL должен включать в себя несколько обьязательных полей: username, password, grant_type и scope. В ответ сервер должен прислать токун доступа и токен обновления, если веденные данные праивльные и пользователь существует на сервере. В противном случае сервер вернет ошибку.

Форма ввода для данного компонента распологается в файле частичного представления /Views/Partial/LoginComponent.cshtml

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*,AngularSpa"
@using AngularSpa.ViewModels
@model LoginViewModel

<div class="jumbotron center-block login-form">
    <h2>Вход</h2>

    <div *ngIf="errorMessage != null">
        <div class="error-box">{{ errorMessage  }}</div>
    </div>

    <form role="form" #loginForm="ngForm">
        <div *ngIf="loginViewModel != null">
            <tag-di for="Login"></tag-di>
            <tag-di for="Password"></tag-di>
            <button type="button" (click)="onLoginClick($event)" class="btn btn-default">Войти</button>
            <span class="small">Еще не с нами? <a routerLink="/register"> Регистрируйся тут!</a></span>
        </div>
    </form>
</div>



Для работы с формой входа нам нужна модель данных LoginViewModel. Добавим новый файл /wwwroot/app/models/LoginViewModel.ts

import { Component } from '@angular/core';
 
export class LoginViewModel {
    login: string;
    password: string;
}

У на суже есть форма регистрации и форма входа. Теперь нам нужен некий защищенный ресурс, доступ к которому мы хотим получить. До этого у нас был сервис для доступа к заказам. Но мы его выбросили из проекта. Теперь нам нужен сервис для доступа к данным пользователя. Мы будем отображать информацию о пользователе на специальной странице. Только залогиненый пользователь сможет смотреть информацию о себе. Доступ к этим данным без входа в систему будет невозможен.

Для этого нам понадобится:

  • Новый API контроллер на стороне сервера
  • Новая модель данных для представления информации о пользователе (как на сервере так и в Angular)
  • Новый компонент Angular, который будет отображать эту информацию
  • Новый сервис Angular, который будет доставать эту информацию с сервера

Начнем с нового API контроллера. Создадим новый файл /Api/ProfilesController.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AngularSpa.ViewModels;
using AspNet.Security.OAuth.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SpaDatasource.Interfaces;

namespace AngularSpa.Api
{
    [Route("api/[controller]")]
    [Authorize(AuthenticationSchemes = OAuthValidationDefaults.AuthenticationScheme)]
    public class ProfilesController : Controller
    {
        ISpaDatasource _SpaDatasource = null;

        public ProfilesController(ISpaDatasource ds)
        {
            _SpaDatasource = ds;
        }

        [HttpGet]
        public ProfileViewModel GetProfile()
        {
            ProfileViewModel profile = null;

            try
            {
                Claim subClaim = this.User.Claims.Where(o => o.Type == "sub").FirstOrDefault();
                if(subClaim != null)
                {
                    int id = int.Parse(subClaim.Value);

                    _SpaDatasource.Open();
                    SpaDatasource.Entitites.User u = _SpaDatasource.FindUserById(id);

                    if (u != null)
                    {
                        profile = new ProfileViewModel
                        {
                            Id = u.Id,
                            FullName = u.FullName,
                            Login = u.Login,
                            Status = u.Status,
                            EmailAddress = u.Email,
                            Country = u.Country,
                            City = u.City,
                            Zip = u.Zip,
                            Address = u.Address
                        };
                    }
                }
            }
            finally
            {
                _SpaDatasource.Close();
            }

            return profile;
        }
    }
}

[Route(«api/[controller]»)] — этот атрибут означает, что контроллер отвечает на URL /api/profiles. Тут [controller] заменяется на имя класса контроллера. Очень удобно, если позже нужно изменить имя класса во время разработки. В продакшене делать этого не стоит, так как изменятся все URL для API, что однозначно очень плохо.

[Authorize(AuthenticationSchemes = OAuthValidationDefaults.AuthenticationScheme)] — этот атрибут означает, что все методы в данном контроллере могут быть вызваны только авторизированым пользователем. Если клиент попробует вызвать метод не авторизировавшись, то получит ошибку HTTP 401 — Unauthorized.

GetProfile — это метод, который будет вызван при HTTP GET запросе с URL /api/profiles

Внутри метода мы извлекаем информацию о уникальном индентификаторе пользователя, который сервер вложил в токен доступа. Это Id поля в БД для авторизированого пользователя.

Далее метод ивлекает данные из БД и возращает их клиенту.

Теперь создадим класс модели данных ProfileViewModel. Для этого создаем новый файл /ViewModels/ProfileViewModel.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.ViewModels
{
    public class ProfileViewModel
    {
        [Display(Description = "Id")]
        public long Id { set; get; }
        [Display(Description = "Статус пользователя")]
        public string Status { set; get; }
        [Display(Description = "Логин")]
        public string Login { set; get; }
        [Display(Description = "Полное имя")]
        public string FullName { set; get; }
        [Display(Description = "Электронная почта")]
        public string EmailAddress { set; get; }
        [Display(Description = "Страна")]
        public string Country { set; get; }
        [Display(Description = "Город")]
        public string City { set; get; }
        [Display(Description = "Почтовый индекс")]
        public string Zip { set; get; }
        [Display(Description = "Адрес")]
        public string Address { set; get; }
    }
}

Так же нужен соотвествующий класс модели в Angular системе. Создадим новый файл /wwwroot/app/models/ProfileViewModel.ts

import { Component } from '@angular/core';

export class ProfileViewModel {
    id: number;
    status: string;
    login: string;
    fullName: string;
    emailAddress: string;
    country: string;
    city: string;
    zip: string;
    address: string;
}

Классы для моделей данных готовы. Можно создать сервис, которы йбудет извлекать данные с сервера. Создадим новый файл /wwwroot/app/services/profile.service.ts

import { Injectable } from '@angular/core';
import { Http, Response, Headers } from '@angular/http';
import { Observable } from 'rxjs/Observable';

import { ProfileViewModel } from '../models/ProfileViewModel';
import { AuthService } from '../security/auth.service';

@Injectable()
export class ProfileService {
    private url: string = 'api/profiles';

    constructor(private http: Http, private authService: AuthService) { }

    getProfileInfo(): Observable<ProfileViewModel> {
        return this.http.get(this.url, { headers: this.authService.authJsonHeaders()})
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }

    private handleError(error: Response | any) {
        let errMsg: string;

        if (error instanceof Response) 
        {
            if (error.status == 401) // Unauthorized
            {
                errMsg = '401 - Требуется авторизация';
            }
            else
            {
                const body = error.json() || '';
                const err = body.error || JSON.stringify(body);
                errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
            }
        }
        else
        {
            errMsg = error.message ? error.message : error.toString();
        }

        return Observable.throw(errMsg);
    }
}

Этот файл очень похож на наш старый сервис для работы с заказами. Поэтому особо на нем останавливаться не буду.

Создадим новый компонент для отображения полученной информации. Создайте новый файл /wwroot/app/components/profile.component.ts

import { Component } from '@angular/core';

import { AuthService } from '../security/auth.service';
import { ProfileService } from '../services/profile.service';
import { ProfileViewModel } from '../models/ProfileViewModel';

@Component({
    selector: 'profile',
    templateUrl: '/partial/profileComponent'
})
export class ProfileComponent {
    profileViewModel: ProfileViewModel;
    errorMessage: string;

    public constructor(private profileService: ProfileService, private authService: AuthService) { }

    ngOnInit() {
        if (this.isLoggedIn()) {
            this.GetProfile();
        }
    }

    GetProfile() {
        this.profileService.getProfileInfo()
            .subscribe((data: ProfileViewModel) => this.profileViewModel = data, error => this.errorMessage = <any>error);
    }

    isLoggedIn() {
        return this.authService.isLoggedIn();
    }
}

При инициализации компонента мы проверяем, что пользователь залогинился, и если да, то отправляем запрос на сервер, для получения информации о текущем залогиненом пользователе. Для этого используется наш новый сервис.

HTML код нашего компонента следующий (это файл /Views/Partial/ProfileComponent.cshtml):

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*,AngularSpa"
@model AngularSpa.ViewModels.ProfileViewModel

<h1>Профиль пользователя</h1>
<hr />

<div [hidden]="isLoggedIn()">
    Вы не авторизированы. Для начала войдите в свой аккаунт.
</div>

<div [hidden]="!isLoggedIn()">
    <div *ngIf="errorMessage != null">
        <div class="error-box">{{ errorMessage  }}</div>
    </div>

    <div *ngIf="profileViewModel != null">
        <div class="panel panel-primary profile-info">
            <div class="panel-heading">Данные пользователя</div>
            <div class="panel-body">
                <tag-dd for="Id"></tag-dd>
                <tag-dd for="Status"></tag-dd>
                <tag-dd for="Login"></tag-dd>
                <tag-dd for="FullName"></tag-dd>
                <tag-dd for="EmailAddress"></tag-dd>
                <tag-dd for="Country"></tag-dd>
                <tag-dd for="City"></tag-dd>
                <tag-dd for="Zip"></tag-dd>
                <tag-dd for="Address"></tag-dd>
            </div>
        </div>
    </div>
</div>


Практически все. осталось добавить новые routes и новые сервисы в Angular компонент, что бы навигация начала нормально работать.

app.routing.ts теперь выглядит так:


import { Routes, RouterModule } from '@angular/router';

import { AboutComponent } from './components/about.component';
import { IndexComponent } from './components/index.component';
import { ContactComponent } from './components/contact.component';
import { LoginComponent } from './components/login.component';
import { RegisterComponent } from './components/register.component';
import { CartComponent } from './components/cart.component';
import { ProfileComponent } from './components/profile.component';

const appRoutes: Routes = [
    { path: '', redirectTo: 'home', pathMatch: 'full' },
    { path: 'home', component: IndexComponent, data: { title: 'Главная' } },
    { path: 'about', component: AboutComponent, data: { title: 'О магазине' } },
    { path: 'contact', component: ContactComponent, data: { title: 'Контакты' } },
    { path: 'login', component: LoginComponent, data: { title: 'Вход' } },
    { path: 'register', component: RegisterComponent, data: { title: 'Регистрация' } },
    { path: 'cart', component: CartComponent, data: { title: 'Корзина' } },
    { path: 'profile', component: ProfileComponent, data: { title: 'Профиль пользователя' } }
];

export const routing = RouterModule.forRoot(appRoutes);

export const routedComponents = [AboutComponent, IndexComponent, ContactComponent, LoginComponent, RegisterComponent, CartComponent, ProfileComponent];

app.module.ts теперь выглядит так:

import { NgModule, enableProdMode } from '@angular/core';
import { BrowserModule, Title } from '@angular/platform-browser';
import { APP_BASE_HREF, Location } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { routing, routedComponents } from './app.routing';
import { AppComponent } from './components/app.component';
import { AuthService } from './security/auth.service';
import { ProfileService } from './services/profile.service';

import './rxjs-operators';

// enableProdMode();

@NgModule({
imports: [BrowserModule, FormsModule, HttpModule, routing],
declarations: [AppComponent, routedComponents],
bootstrap: [AppComponent],
providers: [
AuthService,
ProfileService,
Title,
{ provide: APP_BASE_HREF, useValue: '/' }]
})
export class AppModule { }

ВАЖНО: Если вы откроете проект, то увидите, что структура проекта изменилась, некоторые файлы были удалены, некоторые переименованы. Я описал добавление не всех файлов и описал не весь новый код. В основном потому что он достаточно примитивен, и я это уже делал в предыдущих статьях. Мне кажется что эта чать получилась и так очень большой. Потому я постарался сократить лишний код в статье, его всегда можно посмотреть в Visual Studio.

Я так же осознаю, что не все мои обьяснения достаточно подробные, и не весь код достаточно ясен. Не стесняйтесь, задавайте вопросы в коментариях, я постараюсь ответить как можно скорее.

Так же я разместил готовый проект онлайн, можно пощелкать конечный результат тут: http://spa6.siv-blog.com

Исходный код

Тут можно скачать архив с Visual Studio 2017 проектом ASP.NET MVC Core + Angular SPA
Исходный код на GitHub: https://github.com/SIV-Blog/AngularSpa

Все части серии

Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 1.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 2.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 3.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 4.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 5.
Single Page Application : ASP.NET MVC .NET Core 2 + Angular 5. Часть 6 [читаете сейчас].

4 комментария

  1. Здравствуйте.
    А как работает автообновление шаблона при такой системе?
    Если в обычном исполнении ангуляр проекта поменять хтмл код шаблона,
    то это отобразится на клиенте.
    А здесь как?
    Меняешь cshtml, сохраняешь, и… ничего.
    Ладно, допустим, это от того, что никто не скомпилировал страницу.
    Открываю в другой закладке браузера, чтобы первую не трогать.
    Страница компилится, на 2-й закладке до следующего изменения всё правильно.
    А на 1-й ничего не поменялось.

    Ну тогда может быть 3-й вариант сработает,
    но его надо делать отдельно.
    Может как-то можно проверить изменилось ли скомпилированное представление шаблон на сервере и разослать инфу об этом на клиенты.
    Как сделано для обычных хтмл шаблонов.

    Правда тут ещё есть вопрос:
    Представления при публикации компилируются в dll файлы.
    Как тогда быть?, может тоже что-то такое можно сделать.

Добавить комментарий

Ваш e-mail не будет опубликован.