Чайник: веб-программирование стало проще

Вслед за моим недавно опубликованным учебником по Smalltalk я хотел написать еще одно веб-руководство, на этот раз выделив замечательный веб-фреймворк микро в Pharo. (Если вы не следовали исходному руководству, я предлагаю вам хотя бы прочитать главы 2, 3 и 4. Обратите внимание, что для этого руководства использовать Raspberry Pi не обязательно; подойдет любой компьютер. Установите Pharo отсюда .)

Чайник - красивый, простой и удобный фреймворк для создания веб-сервисов и веб-приложений. Это очень похоже на Python Flask, Ruby Sinatra и Java / Kotlin's Spark.

(Чайник основан на фреймворке Zinc HTTP Components, для которого Свен Ван Кекенберг написал отличный учебник.)

Наше первое приложение для чайника будет довольно обширным интерфейсом для управления входом в систему. Для этого нам понадобятся четыре вещи:

  1. механизм шифрования паролей (используйте Pierce Ng’s PasswordCrypt)
  2. хранилище базы данных для учетных данных (используйте MongoDB и VoyageMongo)
  3. механизм отправки электронной почты (используйте #ZdcSecureSMTPClient от Zodiac)
  4. метод генерации UUID (используйте #UUIDGenerator)

Чтобы установить Чайник, выполните это в Playground:

Metacello new
	baseline: 'Teapot';
	repository: 'github://zeroflag/teapot:master/source';
	load.

Мы будем использовать PBKDF2 для шифрования пароля. Мы будем использовать FFI для вызова библиотеки C для этой цели, потому что код C намного быстрее. Если вашему веб-сайту необходимо обрабатывать десятки входов в систему в минуту, производительность будет реальной проблемой.

Нам потребуются библиотеки OpenSSL C:

sudo apt-get install libssl-dev

Библиотека C для PasswordCrypt должна быть скомпилирована. Используйте прилагаемый Makefile. (В зависимости от вашей системы разработки вы можете или не сможете использовать флаг ‘-m32’. Если нет, просто удалите флаг.) С файлами C в той же папке просто выполните:

make

Поместите файл библиотеки в папку Pharo (или там, где находится Pharo VM).

Чтобы загрузить PasswordCrypt в Pharo:

Metacello new 
   baseline: 'PasswordCrypt'; 
   repository: 'github://PierceNg/PasswordCrypt/src-st'; 
   load

Мы будем использовать популярный MongoDB для нашего хранилища баз данных. Чтобы настроить MongoDB в Debian Linux,

sudo apt-get update
sudo apt-get upgrade
sudo apt-get install mongodb-server
mongo # run mongo shell
sudo service mongodb start # start mongodb as a service
sudo service mongodb stop # stop the service

Voyage - это уровень абстракции сохраняемости объекта, который можно использовать с Mongo. Установите VoyageMongo из обозревателя каталогов (щелкните зеленую галочку, чтобы установить стабильную версию). Вот вводный материал о VoyageMongo.

Чтобы уведомить Voyage о Mongo, выполните это в Playground:

|repo|
repo := VOMongoRepository 
            host: VOMongoRepository defaultHost 
            database: 'NCTDB'.
VORepository setRepository: repo.

Наша база данных называется NCTDB, а модель базы данных представлена ​​классом #NCTUser. Модель создаем так:

Object subclass: #NCTUser
   instanceVariableNames: 'name user pwdHash pwdSalt uuid 
      creationDate accessDate'
   classVariableNames: ''
   poolDictionaries: ''
   category: 'NCT-Tutorial'
NCTUser class>>isVoyageRoot "a class-side method"
   ^ true
NCTUser class>>voyageCollectionName "a class-side method"
   ^ 'NCTUsers'

Также мы хотим создать все «аксессоры» для переменных экземпляра.

Нам нужны следующие фрагменты информации, хранящиеся в «коллекции» базы данных (состоящей из «документов», если использовать язык монго):

  • name - полное имя пользователя (необязательно)
  • user - это адрес электронной почты пользователя, гарантированно уникальный
  • pwdHash и pwdSalt - зашифрованный пароль вместе с связанной с ним солью
  • uuid - UUID - это 128-битное число, используемое для (почти) однозначной идентификации чего-то или кого-то (в нашем случае, пользователя)
  • CreationDate - дата регистрации пользователя; потенциально полезно для целей аудита или истечения срока действия учетной записи
  • accessDate - дата последнего входа пользователя в систему; потенциально полезно для определения того, насколько «просрочен» аккаунт.

Создать документ для нового пользователя так же просто, как:

pwd := 'Amber2017'. "Amber Heard will be my girlfriend in 2017!"
salt := 'et6jm465sdf9b1sd'.
(NCTUser new)
   name: 'Richard Eng';
   user: '[email protected]';
   pwdHash: PCPasswordCrypt sha256Crypt: pwd withSalt: salt;
   pwdSalt: salt;
   uuid: UUID new hex asUppercase;
   creationDate: DateAndTime today;
   save.

Чтобы использовать #ZdcSecureSMTPClient, необходимо включить параметр «Разрешить менее безопасные приложения» для учетной записи Gmail, которую вы используете для отправки электронных писем. Это используется для проверки электронной почты учетных записей пользователей.

Чайная лошадиная дорога

Чайник основан на идее маршрутов. Маршрут состоит из трех частей: 1) HTTP-метод; 2) шаблон URL; 3) Действие - это может быть блок, отправка сообщения или объект. Список маршрутов в основном состоит из вашего веб-приложения.

initialize
   Teapot stopAll. "reset everything"
   Teapot on
      GET: '/register' -> [ self registerPage: 0 name: '' 
         user: '' pwd: '' pwd2: '' ];
      POST: '/register' -> [ :req | self verifyRegistration: req ];
      GET: '/verify/<uuid>' -> [ :req | self verifyUUID: req ];
  
      GET: '/login' -> [ self loginPage: 0 user: '' pwd: '' ];
      POST: '/login' -> [ :req | self verifyLogin: req ];
  
      before: '/welcome/*' -> [ :req |
         req session attributeAt: #user 
            ifAbsent: [ req abort: (TeaResponse redirect 
               location: '/login') ] ];
      GET: '/welcome/<name>' -> [ :req | self mainPage: req ];
  
      GET: '/forgot' -> [ self forgotPage: '' ];
      POST: '/forgot' -> [ :req | self handleForgot: req ];
  
      before: '/profile/*' -> [ :req |
         req session attributeAt: #user 
            ifAbsent: [ req abort: (TeaResponse redirect 
               location: '/login') ] ];
      GET: '/profile' -> [ :req | self profilePage: req ];
      POST: '/profile' -> [ :req | self handleProfile: req ];
  
      GET: '/logout' -> [ :req | self logout: req ];
      GET: '/books' -> [ :req | 'Check ',(req at: #title),' and ',
         (req at: #limit) ]; "this route demonstrates how to pass
         parameters in the URL, eg, 
         /books?title=The Expanse&limit=8"
      start

Например, если вы посещаете страницу входа (для фиктивного URL) в своем веб-браузере…

http://nct.gov/login "nct.gov is a fictitious domain;
                      normally, you will register your own domain
                      and configure your web app to use it"

… Вы увидите веб-форму для входа с вашим именем пользователя и паролем. Это активирует маршрут:

GET: '/login' -> [ self loginPage: 0 user: '' pwd: '' ]

Метод #loginPage: user: pwd: представляет HTML-код для отображения веб-страницы. Когда вы отправляете информацию формы на веб-сервер, это активирует маршрут…

POST: '/login' -> [ :req | self verifyLogin: req ]

… Где #verifyLogin: обрабатывает информацию формы, и после успешной проверки вы попадаете на главную веб-страницу:

GET: '/welcome/<name>' -> [ :req | self mainPage: req ]

Метод #mainPage: представляет HTML-код для отображения страницы приветствия.

Аналогичный процесс применяется к странице регистрации («/ register») и «Забыли пароль?» страницу (‘/ Forgot’) и страницу профиля пользователя (‘/ profile’).

Некоторые маршруты не отображают веб-страницу, например «/ logout», которая просто выводит вас из системы и перенаправляет на страницу входа.

Аргумент «req» - это HTTP-запрос, связанный с HTTP-методом. Он содержит «сеанс», который можно использовать для хранения «глобальной» информации. В нашем случае мы будем хранить «user» (или адрес электронной почты), чтобы определить, когда пользователь вошел в систему. Мы также сохраним «uuid», потому что это весело!

Маршрут #before: представляет собой фильтр, который оценивается перед GET: запросом, который следует сразу за ним. Фильтр используется, чтобы гарантировать, что пользователь вошел в систему, прежде чем он сможет получить доступ к веб-странице.

Что видит пользователь

Различные веб-страницы представляют собой общедоступный вид нашего приложения. Он состоит из «таблицы стилей» (которая содержит инструкции CSS) и целого набора HTML-кода. Ниже приведена страница входа в систему:

loginPage: code user: user pwd: pwd
   ^ '<html> <head>',self stylesheet,'</head>
      <body>
      <h2>Login</h2>
      <div>
      <form method="POST">
         Email:<br>', (self errCode: (code bitAnd: 
            self class ErrBadEmail)), '
         <input type="text" name="user" value="',user,'"><br>
         Password:<br>', (self errCode: (code bitAnd: 
            self class ErrBadPassword)), '
         <input type="password" name="pwd" value="',pwd,'"><br><br>
         <input type="submit" value="Submit">
      </form>
      <p><a href="/forgot">Forgot your password?</a></p>
      <p><a href="/register">Sign up now!!</a></p>
      </div>
   </body>
   </html>'

Вот страница регистрации:

registerPage: code name: name user: user pwd: pwd pwd2: pwd2
   ^ '<html> <head>',self stylesheet,'</head>
      <body>
      <h2>Register</h2>
      <div>
         <form method="POST">
            Fullname:<br>
            <input type="text" name="name" value="',name,'"><br>
            Email:<br>', (self errCode: (code bitAnd: 
               self class ErrBadEmail)), '
            <input type="text" name="user" value="',user,'"><br>
            Password:<br>', (self errCode: (code bitAnd: 
               self class ErrBadPassword)), '
            <input type="password" name="pwd" value="',pwd,'"><br>
            Password (confirm):<br>', (self errCode: (code bitAnd: 
               self class ErrNoPasswordMatch)), '
            <input type="password" name="pwd2" value="',pwd2,'">
               <br><br>
            <input type="submit" value="Submit">
      </form>
   </div>
   </body>
   </html>'

Страницы профиля и «Забыли пароль?» похожи. Что касается #bitAnd:, это побитовый оператор, который обрабатывает числа как последовательности двоичных цифр. Для каждой соответствующей пары битов из операндов оператор ‘and’ (или ‘&’) дает следующие результаты:

  • 0 & 0 - ›0
  • 0 и 1 - ›0
  • 1 и 0 - ›0
  • 1 и 1 - ›1

Есть аналогичные операторы для ‘or’ (или ‘|’), ‘xor’ (или ‘^’) и ‘not’ (или ‘~’).

Однако обратите внимание, что Smalltalk нумерует биты от 1 до 16, не от 0 до 15! (Это аналогично массивам, где Smalltalk начинается с элемента 1, а не с элемента 0. Кстати, в Smalltalk целые числа не ограничиваются 32 или 64 битами, поэтому эти побитовые операторы можно использовать с очень большими числами!

Переменная «code» - удобный способ кодирования нескольких сообщений об ошибках.

Вот таблица стилей:

stylesheet
   ^ '<style>
      body { 
         background-image:
         url(https://cdn-images-1.medium.com/max/2000/1*QVTC39_gW_wMXKwNxUvooA.jpeg); 
         background-size: 100%;
         /*font-family: arial, helvetica, sans-serif;*/
         text-align: center;
      }
/* from https://www.w3schools.com/howto/howto_js_sidenav.asp */
body {
    font-family: "Lato", sans-serif;
}
.sidenav {
    height: 100%;
    width: 0;
    position: fixed;
    z-index: 1;
    top: 0;
    left: 0;
    background-color: #111;
    overflow-x: hidden;
    transition: 0.5s;
    padding-top: 60px;
}
.sidenav a {
    padding: 8px 8px 8px 32px;
    text-decoration: none;
    font-size: 25px;
    color: #818181;
    display: block;
    transition: 0.3s;
}
.sidenav a:hover, .offcanvas a:focus{
    color: #f1f1f1;
}
.sidenav .closebtn {
    position: absolute;
    top: 0;
    right: 25px;
    font-size: 36px;
    margin-left: 50px;
}
@media screen and (max-height: 450px) {
  .sidenav {padding-top: 15px;}
  .sidenav a {font-size: 18px;}
}
   </style>'

HTML и CSS выходят за рамки этого руководства, но для них есть множество ресурсов для онлайн-обучения.

Обработка POST и специальных запросов

Самая важная функция веб-приложения - обрабатывать или обрабатывать HTTP-запросы, помимо простого представления веб-страниц. В нашем приложении есть несколько типов запросов, которые необходимо обработать:

  • Логин - пользователь отправил логин и пароль
  • Выход - пользователь хочет завершить сеанс входа в систему.
  • Регистрация - потенциальный пользователь отправил логин и пароль
  • Подтверждение учетной записи - потенциальный пользователь щелкнул ссылку для подтверждения, отправленную ему по электронной почте.
  • Обновление профиля пользователя - пользователь хочет сменить пароль
  • Восстановление после забытого пароля - пользователю нужен временный пароль, отправленный ему по электронной почте.

Вот, например, обработчик для входа пользователя:

verifyLogin: req
   | code name user pwd doc tries |
   user := req at: #user.
   pwd := req at: #pwd.
   code := 0.
   (self validateEmail: user) 
      ifFalse: [ code := code + self class ErrBadEmail ].
   (self validatePassword: pwd) 
      ifFalse: [ code := code + self class ErrBadPassword ].
   code > 0 ifTrue: [ ^ self loginPage: code user: user pwd: pwd ].
   doc := NCTUser selectOne: [ :each | each user = user ].
   doc ifNil: [ ^ req abort: (TeaResponse redirect 
      location: '/register') ].
   (PCPasswordCrypt sha256Crypt: pwd withSalt: doc pwdSalt) ~= 
      doc pwdHash ifTrue: [ 
         tries := req session attributeAt: #tries 
            ifAbsentPut: [ tries := 0 ].
         tries = 3 ifTrue: [ ^ self messagePage: 'Login' 
            msg: 'Exceeded limit. You''ve been locked out.' ].
         tries := tries + 1.
         req session attributeAt: #tries put: tries.
         ^ self messagePage: 'Login' msg: 'Wrong password.' ].
   req session attributeAt: #user ifAbsentPut: user.
   req session attributeAt: #uuid ifAbsentPut: doc uuid.
   doc accessDate: DateAndTime today; save.
   name := doc name.
   ^ TeaResponse redirect location: '/welcome/', 
      (name substrings = #() ifTrue: [ 'friend' ] 
                             ifFalse: [ name ])

Базовый псевдокод:

Extract username and password from the HTTP request.
Validate username and password. If this fails, report the error(s)
    back to the user.
Query the database for the user document.
Compare the password hash from the database to the hash of the
    submitted password. If they don't match, report the error back 
    to the user. If this happens three times in succession, lock the
    user out (presumably, a hacker is trying to breach security).
The passwords match, so keep track of login status by storing #user
    and #uuid in the HTTP session.
Update the access date for the user in the database.
Redirect the user to the main page after successful login. If the
    user didn't provide a full name, use the name "friend" instead.

Базовый псевдокод для регистрации аналогичен:

Extract username and password from the HTTP request.
Validate username and password. If this fails, report the error(s)
    back to the potential user.
Query the database for the user document. If it exists, report to
    the potential user that the user already exists.
We're ready to create a new user document, so generate a UUID and
    encrypt the password. Create a new database document for the
    user, storing the creation date, too.
Send an account verification email to the new user.

Вот обработчик регистрации:

verifyRegistration: req
   | code name user pwd pwd2 uuid salt |
   name := req at: #name.
   user := req at: #user.
   pwd := req at: #pwd.
   pwd2 := req at: #pwd2.
   code := 0.
   (self validateEmail: user) 
      ifFalse: [ code := code + self class ErrBadEmail ].
   (self validatePassword: pwd) 
      ifFalse: [ code := code + self class ErrBadPassword ].
   pwd = pwd2 
      ifFalse: [ code := code + self class ErrNoPasswordMatch ].
   code > 0 ifTrue: [ ^ self registerPage: code 
      name: name user: user pwd: pwd pwd2: pwd2 ].
   (NCTUser selectOne: [ :each | each user = user ]) ifNotNil: [
      ^ req abort: (self messagePage: 'Register' 
         msg: 'User already exists.') ].
   uuid := UUID new hex asUppercase.
   salt := self generateSalt.
   (NCTUser new)
      name: name;
      user: user;
      pwdHash: (PCPasswordCrypt sha256Crypt: pwd withSalt: salt);
      pwdSalt: salt;
      uuid: uuid;
      creationDate: DateAndTime today;
      save.
   self sendEmail: user subject: 'NCTDB Account Verification' 
      content: 'Please click on the following link to verify your email: http://nct.gov/verify/',uuid.
   ^ self messagePage: 'Register' 
      msg: 'Check your email for account verification.'

Утилиты

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

validatePassword: aPassword
   (aPassword size >= 8) & "at least 8 characters"
   (aPassword matchesRegex: '^.*[A-Z].*$') &
   (aPassword matchesRegex: '^.*[a-z].*$') &
   (aPassword matchesRegex: '^.*\d.*$')
      ifFalse: [ ^ false ].
   ^ true

Здесь мы хотим убедиться, что пароль содержит не менее 8 символов, хотя бы один символ верхнего регистра, хотя бы один символ нижнего регистра и хотя бы один числовой символ.

У нас также есть метод генерации «соли»:

generateSalt
   ^ (String new: 16) collect: [ :each | 
      '0123456789abcdefghijklmnopqrstuvwxyz' atRandom ]

Это выделяет новый объект String размером 16, и для каждого символа в строке мы вставляем символ, выбранный случайным образом из строки цифр и букв.

Посмотреть NCTDB в действии

После того, как вы написали веб-приложение, вы можете запустить его и посмотреть, как оно выглядит. На игровой площадке выполните эту инструкцию:

NCTDB new

Затем в локальном веб-браузере введите этот URL-адрес:

http://localhost:1701/login

Вы можете сделать свое веб-приложение доступным для ваших друзей и семьи, переадресовав порт «localhost: 1701» в маршрутизаторе локальной сети (если ваш компьютер находится за маршрутизатором). Просто войдите в свой маршрутизатор, найдите страницу переадресации портов и добавьте запись HTTP (которая включает ваш внутренний IP-адрес, например, 192.168.0.5, и номер частного порта 1701).

Затем, когда ваши друзья посетят IP-адрес вашего маршрутизатора http: //aaa.bbb.ccc.ddd/login, они увидят страницу входа в NCTDB!

В производственных условиях вам понадобится выделенный сервер для вашего веб-приложения, и вы будете использовать что-то вроде Apache или Nginx с Linux или IIS с Windows. Это выходит за рамки данного руководства.

Как видите, Teapot - хороший и простой способ написать веб-приложение. В нашем учебном приложении от Pharo действительно немного (за исключением того, что вам все еще нужно изучить HTML, CSS, немного JavaScript и то, как использовать MongoDB!).

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

Вы можете воспользоваться этой возможностью, чтобы написать для него модуль «администратора», простое средство CRUD для просмотра, добавления, изменения и удаления пользователей из базы данных. Эту функцию можно безопасно выполнять через Интернет (убедитесь, что администратор использует очень надежный пароль).

Говоря о безопасности, ваше приложение Teapot может обслуживаться через HTTPS, получив сертификат SSL и выполнив что-то вроде этого (это пример Raspberry Pi Linux):

initialize
   | secureServer teapot |
   Teapot stopAll.
   secureServer := (ZnSecureServer on: 1443)
      certificate: '/home/pi/server.pem';
      logToTranscript;
      start;
      yourself.
   teapot := Teapot configure: {  #znServer -> secureServer }.
   teapot
      GET: '/register' -> [ self registerPage: 0 name: '' 
         user: '' pwd: '' pwd2: '' ];
      POST: '/register' -> [ :req | self verifyRegistration: req ];
      GET: '/verify/<uuid>' -> [ :req | self verifyUUID: req ];
      " ... "
      start

Этот код создает безопасный сервер на основе HTTPS и SSL. Затем он настраивает Teapot на использование защищенного сервера вместо обычного HTTP-сервера. Необязательное сообщение #logToTranscript позволяет отслеживать всю активность сервера в Transcript для диагностических целей. (Убедитесь, что вы изменили ссылку подтверждения в обработчике регистрации, чтобы использовать https: вместо http:).

Надеюсь, вы нашли этот урок полезным.

Исходный код

Этот zip-файл содержит код Pharo для FileIn в системном браузере. Сначала разархивируйте. Затем перетащите файл в окно Pharo. Файл Во всем файле.