Список статей этой серии
Часть 1: Строим свой SSO сервер, используя Spring Authorization Server
Часть 4: Строим свой SSO. Vue.js, Регистрация, Сброс пароля
Вступление
Всем привет, мы продолжаем строить собственный SSO Server. Но в начале давайте вспомним, что мы сделали в предыдущей статье:
Подключили Redis
Настроили Swagger
Создали кастомизированный интерфейс для
j-sso
с использованиемVue.js
В предыдущей статье при разборе сохранения данных в Redis был упущен очень важный момент. Кастомизация OAuth2AuthorizationService oAuth2AuthorizationService()
бина, перевод его на работу с Redis. Недавно он был обновлён, поэтому если Вы ещё не прочитали его, пожалуйста вернитесь к разделу Раздел 3.1: Добавляем Redis и прочтите его ещё раз.
В этой статье мы детально разберём и реализуем следующие пункты:
Детально разберём frontend приложение
Создадим механизм регистрации пользователей через отдельную форму регистрации на SSO
Реализуем функцию "Забыли пароль"
Итак, давайте приступим!
Раздел 4.1: Структура frontend приложения
В прошлой статье мы уже разбирали построение простейшего приложения, используя vue-cli
. А также рассмотрели, что из себя представляет сборка frontend приложения. Теперь настало время детальнее погрузиться в структуру нашего приложения и разобрать все его составляющие.
Сразу скажу, я улучшил структуру по сравнению с предыдущей статьей. Также добавил ряд компонентов, которые нам понадобятся при построении приложения. Все исходники данного раздела вы можете смотреть здесь
Первым делом давайте взглянем на наш package.json
файл. В нём настроено подключение необходимых зависимостей для работы приложения. Также указаны скрипты запуска и сборки. И ещё дополнительная метаинформация для систем сборки frontend приложения.
package.json
{
"name": "client",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode=development",
"build": "vue-cli-service build --mode=development",
"build-prod": "vue-cli-service build --mode=production",
"build-test": "vue-cli-service build --mode=testing",
"build-dev-java": "vue-cli-service build --mode=dev-java"
},
"dependencies": {
"@dlabs71/d-dto": "^1.1.0",
"@kyvg/vue3-notification": "^2.9.1",
"@mdi/font": "^7.2.96",
"@vee-validate/i18n": "^4.9.5",
"@vee-validate/rules": "^4.9.5",
"@vuepic/vue-datepicker": "^5.1.2",
"axios": "^1.4.0",
"core-js": "^3.30.2",
"moment": "^2.29.4",
"roboto-fontface": "*",
"uuid": "^9.0.0",
"vee-validate": "^4.9.5",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"vuetify": "^3.3.1",
"vuex": "^4.1.0",
"vuex-persist": "^3.1.3",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@babel/core": "^7.22.1",
"@babel/preset-env": "^7.22.2",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-router": "~5.0.8",
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"sass": "^1.62.1",
"sass-loader": "^13.3.1",
"vue-cli-plugin-vuetify": "~2.5.8",
"webpack-plugin-vuetify": "^2.0.1"
},
"engines": {
"npm": ">=8.0.0",
"node": ">=16.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}
Детальную информацию обо всех возможных настройках этого файла вы можете найти здесь. Команды указанные в секции "scripts"
уже разбирались в предыдущей статье, поэтому на них останавливаться не будем. Сразу перейдём к указанным зависимостям в секции "dependencies"
. Ниже описаны основные используемые зависимости для нашего приложения:
@dlabs71/d-dto
- это мною разработанная библиотека для создания классов DTO, предоставляющая поддержку конвертации из json в dto класс и обратно. Она нам пригодится в следующих разделах данной статьи при реализации API.@kyvg/vue3-notification
- библиотека для создания всплывающих уведомлений.@vuepic/vue-datepicker
- библиотека для создания поля выбора даты.axios
- библиотека для создания HTTP запросов.vee-validate
- библиотека для создания механизмов валидации форм.vue
- сам VUE собственной персонойvue-router
- библиотека для создания механизмов роутинга (навигации).vuetify
- библиотека всевозможных компонентов для быстрого и удобного построения форм.vuex
- библиотека для организации локального хранилища.
В секции devDependencies
указываются зависимости для сборки нашего приложения. Тут мы указываем различные плагины vue
, vuetify
, а также подключаем препроцессор sass
.
Все зависимости можно найти на сайте npmjs.
Стоит сказать несколько слов про секцию "browserslist"
. В ней мы указываем список поддерживаемых браузеров для улучшения сборки приложения. Полную информацию о нём можно найти здесь.
Следующим для нас интересным файлом является vue.config.js
. Здесь мы указываем всю основную конфигурацию нашего Vue приложения. Изучить детально её можно здесь. Ну а мы пройдёмся по основным, указанным нами параметрам:
vue.config.js
const {defineConfig} = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: ["vuetify"],
publicPath: process.env.VUE_APP_NODE_ENV !== "development" ? "/static" : "/",
devServer: {
port: 8080
},
pluginOptions: {
vuetify: {
styles: {
configFile: "src/assets/scss/settings.scss"
}
}
}
});
transpileDependencies
- указываем какие зависимости нужно транспилировать, так как по умолчаниюbabel-loader
игнорирует все файлы из директорииnode_modules
publicPath
- разбирали в предыдущей статье. Нужен для указания базового пути статических ресурсов при сборке.devServer
- указываем настройки дляnodejs
сервера, на котором будет запускаться приложение при его разработке. Указываем порт8080
.pluginOptions
- здесь можно указать необходимые настройки подключенных плагинов, таких какvuetify
. В данной реализации мы указываем для плагинаvue-cli-plugin-vuetify
файл настройки стилей. Узнать подробнее о настройке стилейvuetify
можно тут.
Стоит сразу обратить внимание, где у нас располагаются css
стили (мы используем препроцессор sass
со стилем scss
, поэтому у нас .scss
файлы). Они находятся в директории src/assets/css
. Директория src/assets
предназначена для хранения стилей, изображений, шрифтов и остальных статистических ресурсов необходимых приложению. Поэтому в данной директории у нас находятся изображения и стили.
Все .env
файлы мы разбирали в предыдущей статье. Они предназначены главным образом для хранения переменных среды, используемых в приложении.
Теперь взглянем на следующие файлы:
babel.config.js
- хранит в себе конфигурацию babel (попросту говоря js компилятор). Информацию о нём можно найти здесь.jsconfig.json
- этот файл хранит в себе конфигурацию ресурсов приложения, которые помогают вашему редактору правильно распознавать пути и настройки вашего приложения. Создаётся автоматически при создании приложения с использованиемvue-cli
.директория
public
- хранит в себе корневой html файл нашего приложения, а такжеfavicon.ico
иконку, используемую в заголовках этого файла.
Итак, все сопутствующие файлы и файлы настроек мы разобрали. Настало время перейти к самому интересному - это директория src
. В данной директории находятся все исходники нашего frontend приложения. Как уже говорилось выше, директория asserts
применяется для хранения разного рода статики и стилей, поэтому отдельно её рассматривать не имеет смысла. Итак, с ней всё очевидно. Остальные файлы образуют все внутренности приложения, давайте пройдёмся по ним.
Точкой входа в приложение является файл main.js
. В нём происходит подключение vue plugins
, определённых в остальных директориях, и создание самого vue
приложения. App.vue
- корневой элемент нашего SPA приложения, который указывается при создании в main.js
. В нём указан основной шаблон приложения, который содержит следующие элементы:
<j-exception/>
- подключаем наш компонент для отображения сообщений об ошибках (как правило, о 500-ых ошибках с сервера).<notifications group="main"/>
- указываем месторасположения дескриптора для отображения всплывающих сообщений.<router-view/>
- указываем местоположение отображения страниц приложения изvue-router
.
Можно сказать, что компонент App.vue
реализует шаблон wrapper для страниц приложения.
Далее рассмотрим директории, инкапсулирующие в себе настройки роутинга и локального хранилища. Директория router
- содержит в себе один файл index.js
, который предназначен для настроек навигации приложения, используя библиотеку vue-router
. Также в ней создаётся экземпляр класса Router
, который в дальнейшем используется при создании приложения в файле main.js
. Более подробно изучить настройку vue-router
можно на их официальном сайте.
Директория store
- содержит в себе файл index,js
, в котором происходит настройка vuex
. Данное хранилище предназначено для сохранения информации между переходами страниц. Внутри он будет использовать sessionStorage
доступный в любом браузере. Также внутри мы настроим плагин VuexPersistence
- он необходим для сохранения информации при перезагрузке страницы браузера (так как sessionStorage
будет очищен браузером). Обычно такой информацией являются данные о пользователе: его токены доступа и т.д. Конечно, можно и отдельно хранить эту информацию в localStorage
браузера, или вообще настроить vuex
, хранить всю информацию в localStorage
. Но нам не нужно хранить всю информацию в данном приложении постоянно, поэтому нам достаточно sessionStorage
. А чтобы не изобретать "велосипедов", мы просто используем специальный плагин для сохранения строго определённой информации между перезагрузками страницы.
Директория modules
предназначена для создания подключаемых модулей в конфигурацию vuex
. Эти модули представляют собой js файлы, в которых описан блок хранимой информации с собственными функциями управления ею. Более детальную информацию о модулях vuex
можно найти в официальной документации. На данный момент мы добавили один модуль - security
. Он предназначен для хранения основной информации о пользователе и его списке привилегий.
Настало время поговорить об интересной директории global
. Это директория предназначена для подключения всех необходимых: vue плагинов, компонентов, директив, миксинов и т.д. Каждый тип находится в своей директории и имеет в ней корневой файл, в котором происходит подключение всего остального содержимого директории. Этот файл представляет собой vue плагин. Например, в директории components
находится файл components-lib-plugin.js
, который представлен ниже. Как мы видим, он просто подключает все остальные компоненты необходимые для построения приложения.
components-lib-plugin.js
import JTextField from './uikit/j-textfield.vue';
import JDatePicker from "./uikit/j-date-picker.vue";
import JReqDoneIndicator from "./uikit/j-req-done-indicator.vue";
import JSectionDivider from "./uikit/j-section-divider.vue";
import JAvatar from "./uikit/img/j-avatar.vue";
import JImg from "./uikit/img/j-img.vue";
import JExceptionPlugin from "./uikit/j-exception/j-exception-plugin";
import JLogo from "./specified/j-logo.vue";
export default {
install(Vue, opts) {
Vue.component(JTextField.name, JTextField);
Vue.component(JDatePicker.name, JDatePicker);
Vue.component(JReqDoneIndicator.name, JReqDoneIndicator);
Vue.component(JSectionDivider.name, JSectionDivider);
Vue.component(JAvatar.name, JAvatar);
Vue.component(JImg.name, JImg);
Vue.use(JExceptionPlugin, opts);
Vue.component(JLogo.name, JLogo);
}
}
Все подобные файлы подключаются в файле global-lib-plugin.js
, который в свою очередь подключается в файле main.js
. Таким образом, убирается весь хаос с импортами подключения, регистрацией компонентов и т.д., который мешает видеть основную суть, а также упрощает поддержку и развитие этого решения. Стоит сказать про обязательные параметры, которые необходимы данному плагину. Этими параметрами выступают экземпляр Vuex
и Vue-Router
. Указываются они при подключении в main.js
:
main.js
// .....
createApp(App)
.use(router)
.use(store)
.use(GlobalLib, {
store: store,
router: router
})
.mount('#app');
// .....
Данные параметры используются в миксинах, плагинах и компонентах, как вы увидите далее.
Давайте чуть детальней погрузимся в каждую из директорий, чтобы понять, что в ней находиться. Первая директория это components
. Как уже было сказано, в ней находятся все компоненты.
В данной директории есть несколько поддиректорий - uikit
и app
. В директории uikit
находятся компоненты, образующие общий набор компонентов для построения ui форм, такие как: поля ввода, кнопки, другие компоненты управления. В директории app
находятся компоненты предназначенные только для использования внутри этого приложения, и непосредственно относящиеся либо к бизнес-логике, либо к оформлению приложения. Смысл такого разделения в том, чтобы иметь возможность в дальнейшем переиспользовать директорию uikit
в другом проекте.
Давайте детальней взглянем на директорию uikit
.
На данный момент тут находятся компоненты, являющиеся обёртками для компонентов vuetify
. Зачем это сделано? Сделано это для того, чтобы упростить настройку компонента на форме и все компоненты одного типа свести к единому стилю. Компоненты vuetify
славятся своим богатым набором параметров для кастомизации, и зачастую из-за этого на формах происходит полный хаос и неразбериха. Да ещё и код шаблона формы получается огромным, несмотря на то, что на ней находится всего пару полей. Поэтому я всегда в своих проектах применяю данный подход с компонентами-обёртками.
Иногда компоненты могут быть полностью созданными с нуля и вдобавок требовать подключения модуля для store
. В этом случае применяется подход как в компоненте d-exception
. В нём создается файл с компонентом vue, модуль для vuex, а также vue-плагин, в котором происходит его подключение. Далее в файле components-lib-plugin.js
мы подключаем уже сам плагин.
Ниже представлен данный vue-плагин с комментариями. Модуль для vuex подключается динамически. Для работы данного плагина необходимо передать в параметрах экземпляр Vuex в качестве параметра store
. Именно поэтому везде мы прокидываем options
для плагинов.
j-exception-plugin.js
import JException from './j-exception.vue';
import exceptionStoreModule from './j-exception-store-module';
export default {
install(Vue, options) {
options = options || {};
// получим из параметра экземпляр vuex
let store = options.store;
// если нет, то попробуем получить его из экземпляра Vue.
if (!store) {
store = Vue.$store;
}
// если нет, то выбрасываем исключение
if (!store) {
throw new TypeError("store is required in options, if not define in Vue instance");
}
// регистрируем store модуль
store.registerModule('exceptionStoreModule', exceptionStoreModule);
// регистрируем глобальный компонент
Vue.component(JException.name, JException);
}
}
Список всех компонентов и зачем они нужны представлен ниже:
d-exception
- компонент предназначенный для вывода ошибки при http запросах. Показывает сообщение и stacktrace, если есть. Использует vuex для хранения информации об ошибке. Работает в связке сaxios-plugin.js
.d-date-picker.vue
- компонент представляет собой пред-настроенную версию поля для выбора даты. Использует библиотеку@vuepic/vue-datepicker
.d-req-done-indicator.vue
- небольшой компонент для отображения индикатора.d-section-divider.vue
- небольшой компонент для создания линии разделения.d-textfield.vue
- компонент построенный поверхv-text-field
изvuetify
для создания поля ввода текста.
Теперь разберём, что находится в директории plugins
:
plugins-lib-plugin.js
- основной файл для подключения остальных плагинов.axios-plugin.js
- тут происходит настройка axios со всеми необходимыми интерцепторами и обработчиками ошибок http запроса.vee-validate.js
- подключение и настройка библиотекиvee-validate
для создания механизмов валидации форм.vuetify-plugin.js
- плагин подключения и настройкиvuetify
.
У вас может возникнуть вопрос: зачем нам mixin
и functions
, и как они между собой связаны. Дело в том, что при использовании Vue 3
у нас появляется возможность использования Composition API. При таком подходе миксины не нужны, а нужны функции которые бы содержали те же вспомогательные методы. Поэтому у нас есть директория functions, в которой находятся js файлы с вспомогательными функциями, и есть mixin для поддержания Options API. Ниже описаны для чего они нужны:
notification-funcs.js
иnotification-mixin.js
- удобные функции для отображения всплывающих уведомлений.date-helper-funcs.js
- удобные функции для работы с датой и временем основанные наmoment
.
После того как мы разобрали все вспомогательные механизмы, компоненты и т.д. настало время разобраться с директорией views
. Как понятно из названия, она содержит все формы нашего приложения. Есть некая идеология, которую я заложил в структуру этой директории. Она гласит, что каждая простая форма представляет собой "микро-приложение" инкапсулированное в собственной директории. Оно состоит из:
одной (главной для себя) формы в виде vue компонента
своего слоя
service
, где происходит обращение к серверу по HTTP и обработка полученной информацииа также может содержать свои собственные компоненты, миксины, utility функции, которые обязаны быть использованы только в рамках данного "микро-приложения".
При этом могут быть формы, которые внутри себя содержат ещё несколько форм. Например: формы, которые имеют общий контейнер. Данные формы ("микро-приложения") могут быть объединены в одной директории. Как правило, структура соответствует вложенности роутинга в router/index.js
.
Соответственно, в директории у нас есть две поддиректории:
sing-view
- предназначена для всего что связанно с аутентификацией и регистрацией.home
- предназначена для внутренней работы приложения после аутентификации.
sing-view
на данный момент содержит файл sign-view.vue
(контейнер) директорию login
и components
. В components
находятся все необходимые компоненты для построения внутренних форм. login
- это "микро-приложение", которое инкапсулирует в себе всё, что связанно с формой аутентификации. Далее, наравне с директорией login
, появится директория registration
, которая в себе будет содержать всё, что связанно с формой регистрации (свои собственные компоненты, слой service и т.д.). Компоненты, которые находятся в директории sign-view/components
, могут быть использованы как в login
, так и в registration
. Аналогично можно создать общие между этими формами вспомогательные функции, слой service, микcины и т.д.
Для полноценной демонстрации работы нашего j-sso
была кастомизирована главная страница. В ней добавлена логика получения данных по аутентифицированному пользователю и вывод их на странице. Обратите внимание, как построен слой service для главной страницы. Используя библиотеку @dlabs71/d-dto
, мы вначале построили модель ответа от сервера, а потом построили метод получения данных с сервера. Всю необходимую документацию по использованию этой библиотеки можно найти здесь.
Ниже представлена данная модель:
home-model.js
import {DATA_TYPE, JsonField, TypeArr, TypeNumber, TypeString} from "@dlabs71/d-dto";
export class UserModel {
@JsonField("id") @TypeString id;
@JsonField("firstName") @TypeString firstName;
@JsonField("lastName") @TypeString lastName;
@JsonField("middleName") @TypeString middleName;
@JsonField("birthday") @TypeString birthday;
@JsonField("username") @TypeString username;
@JsonField("email") @TypeString email;
@JsonField("authorities") @TypeArr(DATA_TYPE.STRING) authorities = [];
@JsonField("avatarUrl") @TypeString avatarUrl;
}
Также при построении класса API в файле home-service.js
мы использовали вышеуказанную библиотеку и сделали очень маленький и лаконичный метод для получения всех данных. Данный метод представлен ниже. При использовании декоратора @GetMapper(UserModel)
метод будет возвращать Promise<UserModel>
. Таким образом, позволяя сразу работать с телом ответа.
home-service.js
import axios from 'axios';
import {GetMapper} from "@dlabs71/d-dto";
import {UserModel} from "@/views/home/service/home-model";
export class HomeAPI {
static __USER_DATA_URL = "/security-session/user";
@GetMapper(UserModel) getCurrentUser() {
return axios.get(HomeAPI.__USER_DATA_URL);
}
}
На бэке j-sso
для корректной работы главной страницы необходимо добавить новый endpoint в контроллере UIController
.
UIController.java
@Controller
public class UIController {
@GetMapping("/login")
public String login() {
return "index";
}
// добавили новый путь, соответствующий пути для главной страницы.
@GetMapping("/home")
public String home() {
return "index";
}
}
А также нужно изменить значение проперти spring.security.oauth2.authorizationserver.authentication-success-url
для того, чтобы после успешной авторизации нас перенаправляло на главную страницу.
application.yml
spring:
// .....
security:
oauth2:
authorizationserver:
issuer-url: http://localhost:7777
introspection-endpoint: /oauth2/token-info
authentication-success-url: http://localhost:7777/home # было http://localhost:7777/
custom-handler-header-name: J-Sso-Next-Location
// .....
Помимо всего этого нам необходим контроллер получения данных о пользователе. Для этого мы построим SessionController
, в котором сейчас будет находиться только один endpoint.
SessionController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/security-session")
public class SessionController {
@GetMapping("/user")
public AuthorizedUserDto getCurrentUser() {
AuthorizedUser authorizedUser = SecurityUtils.getAuthUser();
return AuthorizedUserDto.build(authorizedUser);
}
}
Ниже представлено, как работает весь данный механизм.
Итак, мы полностью разобрали структуру frontend приложения. Теперь мы можем приступить к более увлекательной части - создание механизма регистрации.
Исходники данного раздела смотрите здесь.
Раздел 4.2: Регистрация пользователя и некие общесистемные плюшки
Прежде чем сразу кидаться и строить форму регистрации, необходимо детально продумать весь механизм. Он не совсем простой, как кажется на первый взгляд. Предположим, что у нас есть форма регистрации и нужный endpoint для приёма информации, какие проблемы тут нас могут поджидать?
Так как это endpoint на регистрацию, он должен быть выведен из под security. Это очень небезопасно, если информация сразу будет помещаться в БД.
Из-за пункта (1) может появиться огромное количество неиспользуемой информации, после чего нам нужно будет решать, как удалять её.
Решить вторую проблему можно при помощи механизма подтверждения регистрации. А как решить первую проблему? Как сделать так, чтобы не создавать сразу пользователей в БД? Стоит вспомнить, что мы в прошлой статье настроили подключение к Redis. Вот здесь он нам и пригодится. При регистрации, мы можем сохранять всю регистрационную информацию в Redis. Также он сразу сможет помочь решить проблему неиспользуемой информации за счёт того, что в Redis можно указать время жизни записи.
Как можно реализовать механизм подтверждения регистрации? Так как помимо user_id
, уникальным ключом идентифицирующим пользователя может быть email
, то можно реализовать подтверждение регистрации через механизм OTP (One Time Password, Одноразовый пароль). Используя данный механизм, после отправки данных будет посылаться сообщение на указанный при регистрации email пользователя с одноразовым паролем. Который далее необходимо будет ввести где-то в системе, чтобы подтвердить регистрацию.
Из всего вышесказанного мы можем получить следующий механизм регистрации
Заполняем все необходимые данные на форме (email, ФИО, дата рождения, пароль).
Данные отправляются на сервер по открытому endpoint-у
После чего данные помещаются в Redis как есть, и указывается время жизни эти данных (T)
Далее должно отправиться email сообщение с одноразовым паролем (OTP)
Система должна открыть форму для ввода OTP
Пользователь проверяет свою email почту, берёт OTP, вводит данный пароль на форме
Система проверяет корректность пароля. После успешной проверки система создаёт пользователя и все сопутствующие данные для корректного его функционирования
Далее пользователю открывается форма информирующая об успешной регистрации
Создаем необходимые формы
Давайте подумаем, как для пользователя это будет выглядеть. Во-первых, вспомним про то, что когда пользователь в первый раз авторизуется в нашем j-sso через yandex, google или github, он автоматически будет создан. Так как через данный тип авторизации невозможно авторизоваться кроме как через реальный почтовый ящик, то этап с подтверждением регистрации, думаю, совершенно излишен. Поэтому, если пользователь выберет просто регистрацию через social oauth, то после получения ответа от соответствующего сервиса об успешной аутентификации, можно смело создавать пользователя и сохранять его в БД. После чего пользователь сразу становится зарегистрированным.
Соответственно, первая форма на которую пользователь должен попасть, нажав на кнопку "Регистрация", должна быть форма с выбором механизма регистрации (либо через отдельную форму, либо через указанные сервисы). Конечно, при нажатии на соответствующую кнопку на форме логина пользователь по сути пройдёт авторизацию, но это совершенно неочевидно для пользователя, поэтому я бы рекомендовал убрать эту возможность с формы логина. В текущем проекте мы с этим пока заморачиваться не будем.
Итак, с регистрацией через сервисы всё ясно. Давайте продумаем, как будет выглядеть форма регистрации, если пользователь захочет пройти её через отдельную форму. Прежде всего, пользователь должен ввести свои данные. Исходя из нашей таблицы sso.users
он должен ввести следующие данные:
Поле |
Обязательность |
Тип данных |
Особенности |
---|---|---|---|
e-mail адрес |
Да |
Строка |
Нужна проверка по маске ввода для e-mail адреса |
Имя |
Да |
Строка |
Не более 100 символов |
Фамилия |
Да |
Строка |
Не более 100 символов |
Отчество |
Нет |
Строка |
Не более 100 символов |
Дата рождения |
Нет |
Дата |
|
Пароль |
Да |
Строка |
Не более 100 символов |
Подтверждение пароля |
Да |
Строка |
Не более 100 символов |
Я хочу реализовать это не одной большой формой, а на форме такого же размера, как и форма логина. Все поля в такую форму не влезут, поэтому разобьём на два этапа. Первым этапом пользователь вводит данные о себе, вторым этапом пользователь придумывает пароль.
После того как пользователь введёт все данные о себе, необходимо отправить их на сервер. Наш j-sso положит их в Redis с указанным TTL (Time To Live), пусть это будет 180 секунд. Далее необходим механизм подтверждения регистрации. Самым очевидным способом видится подтверждение регистрации через email, указанный при регистрации. На него будет отправлен короткий пароль, который нужно будет ввести где-то в нашем j-sso
. Всё это означает, что после ввода всех данных, у пользователя должна открыться форма для ввода одноразового пароля. Сервер, в свою очередь, должен сгенерировать некий короткий одноразовый пароль и отправить его на указанный почтовый ящик при регистрации. Одноразовый пароль тоже должен иметь свой TTL. Раз у нас данные хранятся 180 секунд, то пусть и пароль будет храниться сколько же.
После успешного ввода одноразового пароля на форме сервер должен создать запись в БД об этом пользователе. После чего пользователь должен быть проинформирован об успешном подтверждении его регистрации и отправлен на форму логина. Ниже представлены макеты форм:
Форма логина
Форма выбора варианта регистрации
Форма ввода основных данных
Форма ввода пароля
Форма ввода OTP
Завершающая форма
Выглядит всё достаточно просто. Давайте подведём промежуточный итог всего вышесказанного:
форму любую создать можно без особых проблем
поместить данные в Redis можно используя нами подключенную зависимость
spring-boot-starter-data-redis
. Указать TTL при сохранении тоже возможно.доступ к БД мы настраивали в прошлых статьях
Сгенерировать OTP можно с использованием
org.apache.commons.lang3.RandomStringUtils
, либо написав собственный маленький генератор на основе классаjava.util.Random
Единственное, что мы не разбирали так это отправка email сообщений. Но с этим тоже проблем особых нет, вы можете использовать проект Jakarta Mail
для этого. Ну а я недавно создал небольшую библиотеку для удобной и простой отправки сообщений в подобных механизмах, поэтому буду использовать её. Она опубликована в репозитории Maven Central d-email
. Документацию по ней вы можете найти в её github репозитории.
Итак, больше никаких блокирующих факторов для создания подобного механизма у нас нет. Давайте приступим к его реализации.
Первым делом создадим необходимые формы. Для этого создадим директорию regisntation
в директории views/sign-view
. В ней будет располагаться всё, что касается форм регистрации. Сама регистрация у нас состоит из 5 этапов (формы перечислены чуть выше). Соответственно, в файле registration.vue
можно найти всю логику переключения между формами. Она достаточно простая, останавливаться на ней не будем. Сами формы этапов располагаются в директории views/sign-view/regisntation/forms
. В каждой из них находится простейшая логика получения данных из полей формы и сохранение их либо во vuex, либо отправка на сервер.
mode-step-registration.vue
- этап выбора типа регистрацииdata-step-registration.vue
- этап ввода основных данныхpassword-step-registration.vue
- этап ввода пароля и подтверждение пароля пользователяconfirm-step-registration.vue
- этап ввода OTPsuccess-step-registration.vue
- этап информирующий об успешной регистрации
Да, для сохранения данных между этапами мы используем vuex, и модуль его находится в директории views/sign-view/regisntation/store
. Соответственно, данный модуль подключается в наше настроенное локальное хранилище в директории store
. Вся работа с API сервера находится в директории views/sign-view/regisntation/service
.
registration-service.js
- в нём мы определяем сами методы взаимодействия с API по средствам axios.models.js
- находится DTO для данных регистрации. Всё это строится по средствам библиотеки@dlabs71/d-dto
(документацию по ней вы можете найти здесь).
Если вы внимательно посмотрели на формы в директории forms
, то вы могли заместить, что в некоторых из них логика вынесена в компоненты, которые находятся views/sign-view/components
. Это сделано "на будущее". Как вы дальше увидите в статье, эти компоненты будут использованы при создании функции "Забыли пароль". Описание данных компонентов представлено ниже:
password-form.vue
- ввод пароля и подтверждение пароля пользователяconfirm-form.vue
- ввод OTPsuccess-form.vue
- форма информирующая об успешной регистрации
Также не забываем обновить конфигурацию router.js
. Остановимся на ней чуть детальней:
router.js
const routes = [
{
// всё что касается авторизации / регистрации будет находиться по пути '/auth'
path: '/auth',
component: SignView,
children: [
{
path: 'login',
name: 'login',
component: LoginForm,
},
// добавляем путь для формы регистрации
{
path: 'registration',
name: 'registration',
component: RegistrationForm,
},
]
},
{
path: '/home',
component: HomeView
}
];
const router = createRouter({
// Добавим "контекст" для клиента
history: createWebHistory("/client"),
routes,
});
// Добавим хук, который будет выполняться при каждом переходе и проверять авторизован ли пользователь.
// Если нет, то будет совершать переход на страницу входа.
router.beforeEach((to, from, next) => {
if (to.name && to.path) {
// если пользователь не авторизован и мы переходим на любую страницу кроме "login" и "registration",
// то перенаправлять нас на страницу "login"
if (!store.getters.isAuth && !["login", "registration"].includes(to.name)) {
router.replace({name: 'login'});
return;
}
next();
}
});
export default router;
Во-первых, добавляем путь для формы регистрации. Во-вторых, добавим хук, который будет выполняться при каждом переходе и проверять авторизован ли пользователь. Если пользователь не авторизован, то в таком случае перенаправлять на страницу логина. Определять авторизован пользователь или нет будем по средствам getter-а isAuth
из vuex хранилища (подключенный модуль security.js
). Самое интересное нас ждёт в строчке createWebHistory("/client")
, здесь мы добавляем "контекст" нашего frontend приложения. Но это всё не просто так. Вспомним, что в предыдущих статьях сервер возвращал frontend приложение по пути /login
, endpoint для которого был определён в UIController
. Ниже представлено как он выглядел:
UIController.java
@Controller
public class UIController {
@GetMapping("/login")
public String index() {
return "index";
}
}
Теперь же, наше приложение начало расти, в нём стали появляться новые пути и формы. И один этот бедный endpoint не может предоставить поддержку всех новых путей клиента. Делать под каждую новую форму отдельный endpoint на сервере решение плохое. Это как минимум усложняет поддержку и развитие приложения. Поэтому мы идём на маленькую хитрость и у клиента установим "контекст" (или базовый путь), с которого будут начитаться все пути клиентского приложения. В свою очередь, на сервере мы изменим контроллер, который будет возвращать клиент. И сделаем его следующим:
ClientController.java
@Controller
public class ClientController {
@GetMapping("/client/**")
public String index() {
return "index";
}
}
Таким образом, любой путь, который будет начинаться с client/...
, будет попадать на выше-представленный endpoint и возвращать клиентское приложение. Далее после загрузки браузером приложения, наш router подхватит путь и отобразит необходимую форму.
На этом разбор клиентского приложения мы закончим и приступим к разбору серверной части. На изображениях ниже представлены все созданные формы frontend приложения.
Прежде чем мы приступим к разбору логики работы endpoint-ов регистрации нам необходимо сделать маленькое отступление и разобрать несколько общесистемных функциональностей, который мы ещё не разбирали. Это ExceptionHandling и ResourceBundle.
ResourceBundle
В любом приложении необходимо где-то хранить разнообразные сообщения, наименования и др. Более того, иногда требуется их локализация. Для этого в пакете java.util
существует класс ResourceBundle
. А в Spring у нас есть очень удобный класс ResourceBundleMessageSource
, который умеет работать с ресурсами и опирается на реализацию ResourceBundle
. Так давайте используем его и настроим возможность хранить и использовать сообщения, наименования и т.д. Для этого нам необходимо создать бин ResourceBundleMessageSource messageSource()
в RootConfig
.
RootAppConfig.java
public class RootAppConfig {
// ......
/**
* Бин необходимый для работы с сообщениями хранимыми в .properties файлах.
*/
@Bean
public ResourceBundleMessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("messages");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
// .....
}
Так как мы указали при создании basename равный messages
. То в ресурсах нашего j-sso
необходимо создать Resource Bundle с именем messages.properties
. Также укажем только русскую локаль и будем её заполнять сообщениями по мере создания приложения. Посмотреть содержимое класса можно в репозитории.
Помимо этого, хорошо бы иметь некий класс-сервис, при помощи которого можно легко и быстро доставать значения по их коду. Для этого создадим MessageService
.
MessageService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageService {
public static Locale russian = new Locale("ru", "RU");
private final ResourceBundleMessageSource messageSource;
/**
* Получить сообщение по его коду.
*
* @return сообщение соответствующее коду или при отсутствии значения сам код.
*/
public String getMessage(String code) {
return getMessage(code, russian);
}
/**
* Получить сообщение по его коду и локали
*
* @return сообщение соответствующее коду или при отсутствии значения сам код.
*/
public String getMessage(String code, Locale locale) {
try {
return messageSource.getMessage(code, null, Optional.ofNullable(locale).orElse(russian));
} catch (Exception e) {
log.warn("Get message key='" + code + "' error:" + e.getMessage());
}
return code;
}
/**
* Получить сообщение по его коду и применить форматирование строки используя args.
*
* @return сообщение соответствующее коду или при отсутствии значения сам код.
*/
public String getMessage(String code, Object... args) {
return messageSource.getMessage(code, args, russian);
}
}
Итак, теперь у нас есть возможность хранить разнообразные сообщения, наименования и т.д. в специальных .property
файлах. Есть возможность их локализации, а также есть удобный интерфейс для работы с данным хранилищем.
ExceptionHandle
Теперь давайте поговорим об ExceptionHandle. На самом деле разговор об этом выходит за рамки нашей статьи, думаю, позже я напишу об этом отдельную статью, в которой расскажу свой опыт в настройке обработки Exception в приложениях. А также, как и какие исключения нужно отлавливать, как организовать удобную иерархию классов исключений для бизнес-логики, и как передавать на фронт дополнительную информацию вместе с исключением.
Но сейчас нам всё равно придётся настроить это, так как наше приложение растёт, и бизнес-логика его начнёт скоро требовать передачу информации об ошибках на фронт. Например, при регистрации хорошо бы проверять существует ли уже полученный email или нет, и если да, то вернуть об этом информацию на фронт. Или при аутентификации вернуть информацию, что логин или пароль не верны.
Первым делом нам необходимо создать классы исключений, которыми мы будем оперировать в сервисах. Как правило, их именуют ServiceException
или BusinessLogicException
. Мы создадим ServiceException
, который будет унаследован от RuntimeException
, и выглядеть он будет следующим образом:
ServiceException.java
@Getter
@Setter
public class ServiceException extends RuntimeException {
private String description;
public ServiceException(String description, Throwable cause) {
super(cause.getMessage(), cause);
this.description = description;
}
public static InformationException.InformationExceptionBuilder builder(String description, Throwable throwable) {
return new InformationException.InformationExceptionBuilder()
.description(description)
.throwable(throwable);
}
public static InformationException.InformationExceptionBuilder builder(String description) {
return new InformationException.InformationExceptionBuilder()
.description(description);
}
@Setter
@Accessors(chain = true, fluent = true)
public static class ServiceExceptionBuilder {
private String description;
private Throwable throwable;
ServiceExceptionBuilder() {
}
public ServiceException build() {
return new ServiceException(description, throwable);
}
}
}
Теперь давайте подумаем, а как должно выглядеть тело ответа j-sso
при ошибках. Как минимум там должно быть какое-то читаемое сообщение и stacktrace. Но давайте вспомним, как фронт у нас обрабатывает исключения. Для отображения исключений в клиентском приложении у нас используется компонент j-exception
, и вся необходимая логика находится в интерцепторе axios. Ниже она показана:
axios-plugin.js
axios.interceptors.response.use(
(response) => {
return response;
},
(data) => {
let response = data.response;
// если нет вообще сети
if ("ERR_NETWORK" === data.code) {
let payload = {
level: "ERROR",
description: "Сервис не доступен. Проверьте подключение к сети Интернет или повторите действие позднее",
stacktrace: []
};
// отображим через j-exception
store.dispatch('setException', payload);
return Promise.reject(response);
}
// если мы не авторизованы
if (response.status === 401) {
// просто перейдём на страницу входа
router.replace({name: "login"});
return Promise.reject(response);
}
// если у нас нет прав доступа
if (response.status === 403) {
let payload = {
level: "ERROR",
message: "Отказано в доступе",
}
// отобразим через всплывающие уведомления
store.dispatch('setNotification', payload);
return Promise.reject(response);
}
// если просто возникла 500-ая ошибка на сервере
let exception = response.data;
if (!!exception) {
let payload = {
level: "ERROR",
description: "Внутренняя ошибка сервиса",
stacktrace: []
};
if (!!exception.message) {
payload.description = exception.message;
}
if (!!exception.stacktrace) {
payload.stacktrace = exception.stacktrace;
}
// отобразим через j-exception
store.dispatch('setException', payload);
}
return Promise.reject(response);
});
Как видно из данного кода, у нас есть два вида ошибок. Первые из них мы отображаем через маленькие всплывающие сообщения. А вторые - это "тяжеловесные" ошибки (обычно 500-е с сервера), которые мы отображаем через отдельное диалоговое окно. В нём мы выводим stacktrace для дальнейшего анализа проблемы. Но стоит вспомнить, что 500-ая ошибка не всегда может быть сигналом о баге, возможно это ошибка бизнес-логики приложения из-за некорректно введённых данных пользователя. Или, как мы выше разбирали, из-за того, что email уже существует. Для этого необходим какой-то ещё механизм. Пусть в теле ответа от сервера при ошибке приходит некий флаг information
, который бы сигнализировал о том, что это ошибка бизнес-логики, и что сообщение необходимо отображать во всплывающем уведомлении. Также понадобиться указать уровень уведомления. Например, в некоторых случаях это может быть ERROR
, а в других WARNING
. В связи с этим мы можем построить следующую DTO:
ErrorResponseDto.java
@Getter
@Setter
@Builder
@AllArgsConstructor
public class ErrorResponseDto {
private boolean informative;
private ErrorLevel level;
private String message;
@JsonSerialize(using = StackElementSerializer.class)
private StackTraceElement[] stacktrace;
public static ErrorResponseDtoBuilder builder(String message, Throwable cause) {
return new ErrorResponseDtoBuilder()
.message(message)
.stacktrace(cause.getStackTrace())
.informative(false);
}
public static ErrorResponseDtoBuilder builder(String message) {
return new ErrorResponseDtoBuilder()
.message(message)
.informative(false);
}
public static ErrorResponseDtoBuilder builder() {
return new ErrorResponseDtoBuilder();
}
}
Учитывая всё вышесказанное, нужно изменить также интерцептор для axios:
axios-plugin.js
axios.interceptors.response.use(
(response) => {
return response;
},
(data) => {
let response = data.response;
// если нет вообще сети
if ("ERR_NETWORK" === data.code) {
let payload = {
level: "ERROR",
description: "Сервис не доступен. Проверьте подключение к сети Интернет или повторите действие позднее",
stacktrace: []
};
// отображим через j-exception
store.dispatch('setException', payload);
return Promise.reject(response);
}
let exception = response.data;
// если указано что ошибка для отображения через уведомления
if (exception.informative) {
let payload = {
level: exception.level,
message: exception.message,
}
// отобразим через всплывающие уведомления
store.dispatch('setNotification', payload);
return Promise.reject(response);
}
// если мы не авторизованы
if (response.status === 401) {
// просто перейдём на страницу входа
router.replace({name: "login"});
return Promise.reject(response);
}
// если у нас нет прав доступа
if (response.status === 403) {
let payload = {
level: "ERROR",
message: "Отказано в доступе",
}
// отобразим через всплывающие уведомления
store.dispatch('setNotification', payload);
return Promise.reject(response);
}
// если просто возникла 500-ая ошибка на сервере
if (!!exception.message || !!exception.stacktrace) {
let payload = {
level: "ERROR",
description: "Внутренняя ошибка сервиса",
stacktrace: []
};
if (!!exception.level) {
payload.level = exception.level;
}
if (!!exception.message) {
payload.description = exception.message;
}
if (!!exception.stacktrace) {
payload.stacktrace = exception.stacktrace;
}
// отобразим через j-exception
store.dispatch('setException', payload);
}
return Promise.reject(response);
});
Итак, мы разработали тело ответа сервера при ошибках. Также сделали его обработку на фронте. Создали специальный класс для исключений в бизнес-логике - ServiceException
. Но согласитесь, что не все ошибки бизнес-логики будут предназначены для отображения через всплывающие уведомления. Например, такие ошибки, как не поддерживаемый тип аутентификации или отсутствие нужной связи в БД, всё-таки нужно отображать, как ошибку работы сервера (т.е. баг). Поэтому давайте создадим ещё один класс исключений InformationException
, он будет предназначен для информационных исключений.
InformationException.java
@Getter
@Setter
public class InformationException extends RuntimeException {
private ErrorLevel level;
private String description;
public InformationException(String description, Throwable cause, ErrorLevel level) {
super(cause != null ? cause.getMessage() : description, cause);
this.level = level;
this.description = description;
}
public static InformationExceptionBuilder builder(String description, Throwable throwable) {
return new InformationExceptionBuilder()
.description(description)
.throwable(throwable);
}
public static InformationExceptionBuilder builder(String description) {
return new InformationExceptionBuilder()
.description(description);
}
public static InformationExceptionBuilder builder(String description, ErrorLevel level) {
return new InformationExceptionBuilder()
.description(description)
.level(level);
}
@Setter
@Accessors(chain = true, fluent = true)
public static class InformationExceptionBuilder {
private ErrorLevel level = ErrorLevel.ERROR;
private String description;
private Throwable throwable;
InformationExceptionBuilder() {
}
public InformationException build() {
return new InformationException(description, throwable, level);
}
}
}
Также был создан enum ErrorLevel
с уровнями ошибки. Обратите внимание, что был создан RegistrationException
, который наследуется от InformationException
, и был создан специально для исключений в процессе регистрации.
Теперь дело осталось за малым, необходимо всего лишь создать обработчики этих исключений и подкладывать в ответе сервера заполненную DTO ErrorResponseDto
. Для этого в Spring существует @RestControllerAdvice
и @ExceptionHandler
. Документацию по ним можно найти здесь.
Соответственно, создадим класс GlobalExceptionHandler
.
GlobalExceptionHandler.java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
private final MessageService messageService;
private final ErrorResponseBuilder errorResponseBuilder;
private final boolean enableStacktrace;
/**
* Конструктор класса
*
* @param includeStacktrace включить или исключить отображение stacktrace в ответе.
* always - включить, never выключить.
* @param messageService бин MessageService
*/
public GlobalExceptionHandler(
@Value("${server.error.include-stacktrace}") String includeStacktrace,
@Autowired MessageService messageService
) {
this.enableStacktrace = "always".equals(includeStacktrace);
this.messageService = messageService;
this.errorResponseBuilder = new ErrorResponseBuilder(messageService);
}
@ExceptionHandler(InformationException.class)
public ResponseEntity<ErrorResponseDto> resolveInformationException(
HttpServletRequest request,
InformationException exception
) {
logRequestException(request, exception);
String description;
if (!StringUtils.hasLength(exception.getDescription())) {
description = messageService.getMessage("common.exception", exception.getMessage());
} else {
description = exception.getDescription();
if (description.startsWith("$")) {
description = messageService.getMessage(description.substring(1), exception.getMessage());
}
}
return new ResponseEntity<>(
ErrorResponseDto.builder()
.informative(true)
.level(exception.getLevel())
.message(description)
.stacktrace(enableStacktrace ? exception.getStackTrace() : null)
.build(),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
@ExceptionHandler(ServiceException.class)
public ResponseEntity<ErrorResponseDto> resolveServiceException(
HttpServletRequest request,
ServiceException exception
) {
logRequestException(request, exception);
String description;
if (!StringUtils.hasLength(exception.getDescription())) {
description = messageService.getMessage("common.exception", exception.getMessage());
} else {
description = exception.getDescription();
}
return new ResponseEntity<>(
ErrorResponseDto.builder()
.informative(false)
.message(description)
.stacktrace(enableStacktrace ? exception.getStackTrace() : null)
.build(),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
@ExceptionHandler({NoResultException.class, EmptyResultDataAccessException.class})
public ResponseEntity<ErrorResponseDto> resolveNoResult(HttpServletRequest request, Exception exception) {
logRequestException(request, exception);
return errorResponseBuilder.makeResponse("entity.empty.exception", exception);
}
@ExceptionHandler({EntityNotFoundException.class})
public ResponseEntity<ErrorResponseDto> resolveEntityNotFound(HttpServletRequest request, Exception exception) {
logRequestException(request, exception);
return errorResponseBuilder.makeResponse("entity.not.found.exception", exception);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponseDto> resolveAccessDeniedException(
HttpServletRequest request,
Exception exception
) {
logRequestException(request, exception);
return errorResponseBuilder.makeResponse("access.denied.exception", HttpStatus.FORBIDDEN, exception, true);
}
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponseDto> resolveException(HttpServletRequest request, Exception exception) {
logRequestException(request, exception);
return errorResponseBuilder.makeResponse("common.exception", exception);
}
private void logRequestException(HttpServletRequest request, Exception exception) {
log.debug("Unexpected exception processing request: " + request.getRequestURI());
log.error("Exception: ", exception);
}
}
В представленном классе помимо обратки исключений InformationException
и ServiceException
, также есть ряд обработчиков исключений. И самое важное, что есть обработка Exception.class
. Она позволяет отлавливать любое исключение, которое не отлавливается выше. В файл messages_ru_RU.properties
добавлены соответствующие сообщения. Обратите внимание на обработку InformationException
и ServiceException
. Если в поле description
указано значение, начинающееся со знака '$', то мы считаем,что после этого знака идёт код сообщения и пытаемся получить его значение по средствам messageService
. Ниже представлено, как это используется в сервисах:
DefaultUserService.java
@Service
@RequiredArgsConstructor
public class DefaultUserService implements UserService {
// .....
private void validateRegistrationDto(RegistrationDto dto) {
if (dto.getEmail() == null) {
throw new RegistrationException("$validation.email");
}
if (dto.getFirstName() == null) {
throw new RegistrationException("$validation.firstname");
}
if (dto.getSecondName() == null) {
throw new RegistrationException("$validation.lastname");
}
if (dto.getPassword() == null) {
throw new RegistrationException("$validation.password");
}
UserEntity user = this.userRepository.findByEmail(dto.getEmail());
if (user != null) {
throw new RegistrationException("$account.already.exist");
}
}
// .....
}
Таким образом, мы настроили обработку исключений в нашем приложении. Пример того, как будет теперь выглядеть 500-ая ошибка от сервера представлен ниже:
Ещё одно маленькое отступление
Мне порядком надоело каждый раз менять значения в application.yml
. Во-вторых, когда мы будем запускать это приложение на разных стендах, нам всё равно понадобится механизм подмены значений. Конечно, можно просто воспользоваться spring profiles (в прошлых статьях мы это уже разбирали, но вы можете также ещё раз это прочесть здесь и здесь). Или можно подкладывать нужный application.yml
файл при старте приложения (об этом можно прочитать здесь).
Но я хочу вам показать ещё один очень удобный способ. Он заключается в использовании Environment Variables. Это особенно удобно при использовании для различных логинов/паролей, IP адресов и остальной информации, которую должен знать только администратор сервиса. А также этот подход позволит администратору сервиса быстро менять такие значения, не прибегая к помощи команды разработки.
Смысл его состоит в том, чтобы заменить необходимые значения на placeholder-ы с соответствующими значениями переменных среды. Ниже представлен обновлённый application.yml
:
# .......
security:
oauth2:
authorizationserver:
issuer-url: http://localhost:7777
introspection-endpoint: /oauth2/token-info
authentication-success-url: http://localhost:7777/client/home
custom-handler-header-name: J-Sso-Next-Location
authorization-ttl: 720000
authorization-consent-ttl: 720000
client:
registration:
github:
clientId: ${JSSO_GITHUB_CLIENT_ID}
clientSecret: ${JSSO_GITHUB_CLIENT_SECRET}
google:
clientId: ${JSSO_GOOGLE_CLIENT_ID}
clientSecret: ${JSSO_GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
yandex:
provider: yandex
clientId: ${JSSO_YANDEX_CLIENT_ID}
clientSecret: ${JSSO_YANDEX_CLIENT_SECRET}
redirect-uri: http://localhost:7777/login/oauth2/code/yandex
authorizationGrantType: authorization_code
clientName: Yandex
provider:
yandex:
authorization-uri: https://oauth.yandex.ru/authorize
token-uri: https://oauth.yandex.ru/token
user-name-attribute: default_email
userInfoUri: https://login.yandex.ru/info
# .......
Как вы видите, значения для clientId
и clientSecret
были заменены на соответствующие имена переменных среды. Такие значения необходимо в обязательном порядке документировать. Как правило, это делается посредствам README.md файла в корне сервиса. Данные файл для j-sso
вы можете посмотреть здесь.
Теперь при запуске приложения, не забудьте добавить необходимые Environment Variables. Если вы используете Intellij IDEA, то добавить их при запуске можно заполнив соответствующую секцию.
Строим backend
Итак, после того как мы настроили недостающие общесистемные механизмы, настало время разработать сам процесс регистрации. Для этого создадим контроллер RegistrationController
, в нём объявим два endpoint-а.
/registration/init
- запуск процесса регистрации. Получение от клиента всех данных, генерация OTP, отправка по email./registration/confirm
- подтверждение регистрации. Получение OTP, проверка, сохранение данных в БД о новом пользователе.
Создадим сервис RegistrationService
и его реализацию DefaultRegistrationService
. В самом сервисе логика банальна и вы самостоятельно и без особого труда сможете её разобрать. Интерес тут представляют интерфейсы **Store
.
OTPStore
- инкапсулирует в себе всю работу с OTP. Генерация, валидация, хранение и т.д.
OTPStore.java
public interface OTPStore {
/**
* Генерация OTP
*/
GenerationResult generate(HttpServletResponse response);
/**
* Валидация OTP
*/
boolean validate(String otp, HttpServletRequest request);
/**
* Получение session Id из HttpServletRequest
*/
String getSessionId(HttpServletRequest request);
/**
* Возвращает актуальную конфигурацию
*/
Config getConfig();
/**
* Результат генерации OTP. Содержит созданную ID сессии и сам OTP.
*/
record GenerationResult(String sessionId, String otp) {
}
/**
* Конфигурация стора
*
* @param cookieName наименование cookie
* @param cookieDomain домен cookie
* @param cookieMaxAge время жизни куки cookie и самого OTP
*/
record Config(String cookieName, String cookieDomain, int cookieMaxAge) {
}
}
В приложении создана реализация этого интерфейса - RedisOTPStore
. Сам бин OTPStore
настраивается в конфигурационном классе BeanConfig
. Обратите внимание на конфигурацию. В ней мы определяем все необходимые данные для специальных Cookie. При генерации OTP создаётся специальный идентификатор сессии, к которой принадлежит этот OTP, чтобы потом мы могли правильно валидировать его. Поэтому в методе generate()
параметром является HttpServletResponse response
, в который указывается специальная Cookie с идентификатором. Идентификатор из себя может представлять банальный UUID. Ниже представлена реализация метода generate()
с комментариями:
RedisOTPStore.java
@Slf4j
public class RedisOTPStore implements OTPStore {
// ....
@Override
public GenerationResult generate(HttpServletResponse response) {
// генерируем одноразовый пароль
String otp = RandomStringUtils.randomNumeric(6);
// генерируем идентификатор сессии
String sessionId = this.generateSessionId();
log.info("Generate OTP = " + otp + ". Generate sessionId = " + sessionId);
// сохраняем пароль в Redis
store.set(SESSION_ID_TO_OTP + sessionId, otp, config.cookieMaxAge(), TimeUnit.SECONDS);
// создаём Cookie
Cookie cookie = new Cookie(config.cookieName(), sessionId);
cookie.setMaxAge(config.cookieMaxAge());
cookie.setDomain(config.cookieDomain());
cookie.setHttpOnly(true);
// указываем новые Cookie в Servlet Response
response.addCookie(cookie);
log.info("Add cookie to response = " + cookie);
return new GenerationResult(sessionId, otp);
}
// ....
}
После того как пользователь получит email сообщение с OTP кодом и введёт его на форме подтверждения регистрации, выполнится метод validate()
. Первым параметром которого является OTP введённый пользователем, а вторым параметром HttpServletRequest request
, из которого мы получим наши специальные Cookie. Далее возьмём из них наш идентификатор. Таким образом, мы сможем проверить валидность отправленного по почте OTP и подтвердить регистрацию пользователя.
Вторым интересным интерфейсом является RegistrationStore
. Он в себе инкапсулирует всю работу с ещё не подтверждёнными регистрационными данными пользователя.
RegistrationStore.java
public interface RegistrationStore {
/**
* Сохранить данные
*
* @param dto данные которые сохраняем
* @param sessionId идентификатор сессии пользователя
*/
void save(RegistrationDto dto, String sessionId) throws Exception;
/**
* Взять и удалить из хранилища данные по sessionId
*
* @param sessionId идентификатор сессии пользователя
*
* @return сохранённые данные или null
*/
RegistrationDto take(String sessionId) throws Exception;
}
Методы у него достаточно простые и очевидные. Реализацией этого интерфейса является класс RedisRegistrationStore
. Его бин создаётся и конфигурируется в конфигурационном классе BeanConfig
. Наверно стоит только отдельно указать на то, что такое sessionId
. Это идентификатор сесcии пользователя, который должен быть эквивалентен аналогичному идентификатору в OTPStore
. Другими словами мы просто должны использовать идентификатор из OTPStore
. Ну это и логично, нам нужно сгенерировать OTP и одновременно сохранить данные. Проще всего всё это хранить под одним ключом, чтобы потом иметь возможность всё это легко и быстро достать. А механизм хранения ключа между запросами для RegistrationStore
обеспечивается за счёт механизма OTPStore
, который мы рассмотрели выше.
Скажем также пару слов об отправке сообщения по email. Для этого взглянем на метод register()
из класса DefaultRegistrationService
.
DefaultRegistrationService.java
@Service
@RequiredArgsConstructor
public class DefaultRegistrationService implements RegistrationService {
// .....
@Override
public void register(RegistrationDto registrationDto, HttpServletResponse response) {
// проверяем что пользователь с таким email ещё не существует
if (userService.existByEmail(registrationDto.getEmail())) {
throw InformationException.builder("$account.already.exist").build();
}
// Создаём OTP
OTPStore.GenerationResult generationResult = otpStore.generate(response);
// Сохраняем данные во временное хранилище
try {
registrationStore.save(registrationDto, generationResult.sessionId());
} catch (Exception e) {
throw InformationException.builder("$happened.unexpected.error").build();
}
// отправляем OTP по email
emailSender.sendHtmlTemplated(
registrationDto.getEmail(),
messageService.getMessage("email.subject.confirm.registration"),
"classpath:mail-templates/registration-confirmed.html",
ImmutableMap.<String, Object>builder()
.put("firstName", registrationDto.getFirstName())
.put("otp", generationResult.otp())
.build()
);
}
// .....
}
Само email сообщение представляет собой не скучный текст, а красивую (на сколько это возможно ????) HTML страничку, которая расположена в ресурсах приложения по пути resources/mail-templates/registration-confirmed.html
. Библиотека d-email
позволяет нам отправлять шаблонизированные сообщения, так как внутри себя использует шаблонизатор Apache Velocity Template
. Но будьте внимательны, верстка email сообщений это не то же самое, что верстка форм для отображения в браузере. При вёрстке email сообщения имеются существенные ограничения как по стилям, так и по формату. Поэтому лучше всего воспользоваться специальным сервисом по созданию HTML сообщения, коих на просторах интернета огромное количество.
Итак, на этом технический обзор всего механизма регистрации мы завершаем. Ниже представлен пример работы всего вышеописанного.
Исходники данного раздела смотрите здесь.
Раздел 4.3: Функция "забыли пароль"
Любой человек хотя бы раз да забывал пароль от того или иного сервиса в своей жизни. Ещё бы, сейчас эти сервисы появляются, как грибы после дождя. Да и требования к паролям всё выше и выше. Поэтому я не мог оставить пользователей в безвыходном положении и не сделать маленькую кнопочку - "Забыли пароль". К тому же данная функция очень интересна в реализации. Давайте подумаем, как можно её реализовать.
Первое, что приходит на ум: по нажатию кнопки "Забыли пароль" на форме входа, мы должны попадать на форму указания email адреса пользователя, для которого мы должны сменить пароль. Но если мы сразу дадим ввести пароль, то это будет некорректно, так как пользователь-злоумышленник сможет менять пароль кому угодно, зная только один email адрес. Тем самым взламывая учетки. Поэтому нужно придумать что-то более сложное и в то же время удобное для пользователя. Просто менять пароль точно так же, как и при регистрации через OTP конечно можно, но такой механизм в рамках смены пароля смотрится как-то не особо безопасно. Так как в принципе при должном владении различными инструментами и парой видеокарт можно быстро подобрать данный пароль, поэтому, думаю, чуть более безопасным подходом будет следующий механизм:
Пользователь вводит email адрес для которого необходимо поменять пароль
Система проверят, что такой пользователь реально существует и отправляет пользователю email сообщение с OTP паролем
Пользователь на форме вводит OTP пароль полученный по почте. Тем самым подтверждая, что указанный email адрес принадлежит ему.
После успешного подтверждения OTP, система отсылает пользователю ещё одно сообщение со ссылкой на страницу смены пароля.
Ссылка содержит путь до нужной страницы, а также некий сложный, временный идентификатор разрешающий менять пароль для пользователя указанного на первом этапе.
Пользователь вводит новый пароль и сохраняет его.
Подход со ссылкой на страницу смены пароля по своей сути очень похож на подход с простым OTP кодом подтверждения смены пароля, который мы изначально обсуждали. Но в отличие от него, здесь используется достаточно длинный и сложный идентификатор, который также действует ограниченное количество времени. Подбор такого идентификатора займёт у большинства злоумышленников большее количество времени нежели время его жизни. Помимо этого, мы всегда можем увеличить сложность данного идентификатора без дополнительных проблем для наших пользователей. Поэтому представленный выше механизм считаю достаточно надёжным для нашего j-sso
. Конечно, можно даже обойтись без шага подтверждения email адреса через OTP. Но думаю данный шаг нас оградит от более продвинутых злоумышленников. В любом случае можно легко убрать этот шаг при необходимости. Ниже представлены макеты форм.
Форма логина
Форма ввода e-mail адреса
Форма ввода OTP для подтверждения e-mail адреса
Форма успешного подтверждения e-mail адреса
Форма ввода нового пароля
Форма информирующая об успешной смене пароля
Итак, давайте приступим к реализации данного механизма. Первым делом создадим необходимые формы. Тут как раз вспомним, что при реализации регистрации мы заложились на будущее и часть форм вынесли в компоненты sign-view
. Напомню, это следующие формы:
password-form.vue
- ввода пароля и подтверждение пароля пользователяconfirm-form.vue
- ввода OTPsuccess-form.vue
- форма информирующая об успешной регистрации
Первые две формы мы как раз можем использовать в 1-3 шаге нашего механизма смены пароля. А третью форму можно использовать для указания успешного или неуспешного завершения. Давайте приступим!
Создадим директорию reset-password
в views/sign-view
. В ней будет инкапсулировано всё, что касается механизма смены пароля. Далее по тому же принципу, что и создание форм регистрации, создадим формы смены пароля. reset-password.vue
- агрегирующая форма. В директории forms
находятся формы под каждый шаг.
init-form-pr.vue
- форма ввода email адресаconfirm-step-pr.vue
- форма ввода одноразового пароляsuccess-step-pr.vue
- завершающая форма процесса подтверждения email адресаpassword-step-pr.vue
- форма ввода нового пароля. Именно эта форма будет открываться при переходе по ссылке из email сообщенияsuccess-step-change-password.vue
- завершающая форма процесса смены пароля. Если смена прошла успешно.error-step-change-password.vue
- завершающая форма процесса смены пароля. Если смена прошла с ошибкой.
Внутри каждой формы достаточно простая логика, поэтому разбирать в статье её не будем. Код данных форм можно найти в репозитории.
Стоит только обратить внимание на onMounted()
хук в reset-password.vue
. Здесь зарыта интересная логика, которая представлена ниже.
reset-password.vue
// ....
onMounted(() => {
// пытаемся достать параметр из строки запроса по имени resetSessionId
const urlParams = new URLSearchParams(window.location.search);
const resetSessionId = urlParams.get('resetSessionId');
// если мы смогли получить параметр resetSessionId
if (!!resetSessionId) {
// сразу переходим к шагу смены пароля
setStep(STEPS.PASSWORD);
store.dispatch('setResetData', {
resetSessionId: resetSessionId
});
router.replace({name: "reset-password"})
}
});
// ....
Для того чтобы не городить городушек с роутами, мы просто при загрузке формы пытаемся получить из строки запроса параметр по имени resetSessionId
. Если мы его получаем, то сразу переходим к шагу смены пароля. Таким образом, URL для формы смены пароля будет следующим: http://localhost:7777/client/auth/reset-password?resetSessionId=asda.....
.
Ниже представлено изображение, как будет выглядеть сообщение у пользователя:
Далее всё банально. Пользователь вводит данные на форме смены пароля, после чего отправляется запрос на бэк, в котором по указанному resetSessionId
в Redis ищется информация о пользователе, который захотел сменить пароль. Если информация найдена, то меняем пароль, если нет, то выбрасываем ошибку.
На сервере нам необходимо сделать отдельный контроллер и объявить там три endpoint-а.
ResetPasswordController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/reset-password")
public class ResetPasswordController {
private final ResetPasswordService resetPasswordService;
/**
* Инициализация смены пароля. Пользователь указываем для какого email нам нужно сменить пароль.
* Если пароль не найден, то будет выброшено исключение.
* Если пароль найден, то будет отправлено сообщение с OTP
*/
@PostMapping("/init")
public void initResetPassword(@RequestPart("email") String email, HttpServletResponse response) {
resetPasswordService.initial(email, response);
}
/**
* Подтверждение email. Пользователь присылает OTP код отправленный ему на email в первом endpoint-е.
* Если подтверждение успешно прошло, то пользователю высылается сообщение со ссылкой на форму смены пароля.
*/
@PostMapping("/confirm")
public void confirm(@RequestPart("otp") String otp, HttpServletRequest request) {
resetPasswordService.confirmEmail(otp, request);
}
/**
* Смена пароля пользователя. В заголовках должен быть указан 'reset-password-session'
*/
@PostMapping("/set")
public void setPassword(@RequestPart("password") String password, HttpServletRequest request) {
resetPasswordService.setNewPassword(password, request);
}
}
Первый и второй запросы нам уже знакомы по механизму регистрации. В /init
, используя OTPStore
(его мы разбирали в разделе про регистрацию), в котором создаются специальные Cookie и генерируется сам одноразовый пароль. После чего Cookie выставляются в HttpServletResponse
и отправляются браузеру. А код отправляется на email, который указал пользователь. Далее /confirm
- endpoint подтверждения и валидации OTP кода. Работает аналогично, как и в процессе регистрации.
DefaultResetPasswordService.java
@Service
@RequiredArgsConstructor
public class DefaultResetPasswordService implements ResetPasswordService {
// .....
@Override
public void confirmEmail(String otp, HttpServletRequest request) {
otp = otp.trim();
if (!otpStore.validate(otp, request)) {
throw new ResetPasswordException("$opt.incorrect");
}
// по идентификатору по OTPStore получаем данные из resetPasswordStore. Там находиться email пользователя.
// Он был сохранён на первом шаге.
String sessionId = otpStore.getSessionId(request);
ResetPasswordStore.StoreItem storeItem;
try {
storeItem = resetPasswordStore.take(sessionId);
} catch (Exception e) {
throw InformationException.builder("$happened.unexpected.error").build();
}
// Генерируем специальный идентификатор сессии, который укажем в email сообщении.
String resetPasswordSessionId = CryptoUtils.hash(sessionId + "-" + otp);
try {
resetPasswordStore.save(storeItem, resetPasswordSessionId);
} catch (Exception e) {
throw InformationException.builder("$happened.unexpected.error").build();
}
// Находим пользователя по email в БД. Он нам нужен, для того чтобы добавить в сообщение имя пользователя.
UserEntity user = userService.findByEmail(storeItem.email());
// отправляем email сообщение.
emailSender.sendHtmlTemplated(
storeItem.email(),
messageService.getMessage("email.subject.reset.password"),
"classpath:mail-templates/reset-password.html",
ImmutableMap.<String, Object>builder()
.put("firstName", user.getFirstName())
.put("resetPasswordUrl", this.getResetPasswordUrl(resetPasswordSessionId))
.build()
);
}
/**
* Генерация URL на форму сброса пароля
* @param sessionId специальный идентификатор сессии
*/
private String getResetPasswordUrl(String sessionId) {
String httpUrl = authorizationServerProperties.getIssuerUrl()
+ authorizationServerProperties.getResetPasswordEndpoint();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(httpUrl);
builder.queryParam("resetSessionId", sessionId);
return builder.build().toUriString();
}
// .....
}
Единственное отличие в том, что при успешной валидации OTP генерируется специальный идентификатор (тот самый resetSessionId
). После чего, он сохраняется вместе с данными об email пользователя в Redis. Пользователю отправляется ещё одно сообщение, в котором указывается ссылка на форму сброса пароля (конечно же используя данный идентификатор). Для создания ссылки на сброс пароля была добавлена новая настройка в application.yml
- spring.security.oauth2.authorizationserver.reset-password-endpoint
. Сам URL на форму сброса пароля создаётся по средствам конкатенации данной настройки и authorizationserver.issuer-url
, а далее указывается resetSessionId
, как параметр запроса.
После того как пользователь перейдёт по ссылке, введёт новый пароль и нажмёт на кнопку "Далее", полетит запрос на /set
. Логика работы этого endpoint-а очень простая и представлена ниже.
DefaultResetPasswordService.java
@Service
@RequiredArgsConstructor
public class DefaultResetPasswordService implements ResetPasswordService {
private static final String SESSION_ID_HEADER = "reset-password-session";
// .....
@Override
public void setNewPassword(String newPassword, HttpServletRequest request) {
// Проверяем существует ли в запросе специальный заголовок
if (request.getHeader(SESSION_ID_HEADER) == null) {
throw new ResetPasswordException("$reset.password.broke");
}
// Пытаемся получить значение специального заголовка.
String resetPasswordSessionId = request.getHeader(SESSION_ID_HEADER);
// Пытаемся получить данные из resetPasswordStore по значению из заголовка
ResetPasswordStore.StoreItem storeItem;
try {
storeItem = resetPasswordStore.take(resetPasswordSessionId);
} catch (Exception e) {
throw InformationException.builder("$happened.unexpected.error").build();
}
// Если данных нет, то выбрасываем ошибку
if (storeItem == null) {
throw new ResetPasswordException("$reset.password.broke");
}
// Если данные есть, то меняем пароль у пользователя. Email берём тот который получили из resetPasswordStore
userService.changePassword(storeItem.email(), newPassword);
}
}
Получаем необходимый идентификатор сессии из специального заголовка. Из ResetPasswordStore
достаём email пользователя по полученному идентификатору. Если ошибок не возникло, то просто меняем пароль у пользователя.
Для данного механизма были созданы два шаблона email сообщений. Их можно посмотреть в репозитории.
После этого пользователь может воспользоваться формой входа и выполнить аутентификацию с использованием нового пароля. Ниже представлен данный механизм в действии.
На этом разбор функции "забыли пароль" мы заканчиваем и переходим к итогам нашей статьи.
Исходники данного раздела смотрите здесь.
Резюме
Итак, мы проделали огромную работу и выполнили все технические и почти все функциональные требования. Давайте вернёмся к первой статье и перечислим ниже выполненные требования:
Технические требования
Использование непрозрачных токенов
Использование последних версий Spring Boot и Spring Authorization Server
Использование Java 17
Использование SPA Vue.JS приложения в качестве фронта SSO
Использование Redis в качестве кэш хранилища (хранение токенов и т.д.)
Использование PostgreSQL в качестве основного хранилища
Подключить Swagger и настроить там авторизацию
Функциональные требования:
Аутентификация пользователей на SSO через форму логина/пароля
Аутентификация пользователей на SSO через Google, Github и Yandex
Авторизация по протоколу OAuth2.1 для моих pet-проектов
Получение информации о пользователе по токену доступа из SSO
Регистрация пользователей через Google, Github и Yandex
Регистрация пользователей через отдельную форму регистрации на SSO
Реализация функции "Забыли пароль"
У нас остается только одно невыполненное функциональное требование.
Возможность управления выданными токенами (отзыв токена, просмотр активных сессий и т.д.)
Его мы разберём в следующей заключительной статье. Также в этой статье мы полностью превратим наш j-sso
в реальное приложение со всеми необходимыми формами. И в дополнение ко всему сказанному разберём, как и какие Security Headers необходимо настраивать.
Полезные ссылки
Исходники смотрите здесь
Frontend
Backend
dyadyaSerezha
Как никогда не работавший с используемыми библиотеками и прочим, не понимаю, как всё это выглядит для конечного пользователя. Я пользовался только решением SSO от IBM для Windows. Там пользователь в начале сессии Windows авторизовался на SSO-сервисе и далее на всех заранее прописанных у SSO-сервиса вебсайтах, нативных и консольных приложениях автоматически заполнялись соответствующие логины/пароли. Сам SSO-сервис был локальным на компе (обычный Windows-сервис). А как это выглядит тут, пока не пойму.