Это продолжение заметки про использование OpenShift в качестве java-хостинга.
В прошлый раз мы разобрались как создавать приложения в облаке OpenShift. В наше распоряжение предоставлен бесплатный
Одна из распространенных задач: разрешить доступ к определенным ресурсам только авторизованным пользователям, с разделением в соответствии с присвоенными ролями. Предлагается сделать это с использованием встроенного в jboss логин-модуля, а именно реализацией org.jboss.security.auth.spi.DatabaseServerLoginModule. Как не трудно догадаться, в этом случае пользователи и их роли будут храниться в базе данных.
Схема данных достаточно проста: это таблица APP_USER (пользователи), APP_ROLE (справочник ролей) и APP_MEMBERSHIP (назначенные роли), через которую реализуется связь много-ко-многим между первыми двумя таблицами.
Создадим в web-консоли новое jbossas-7 приложение с картриджем mysql-5.1 и импортируем его в Eclipse. Следует переключиться в перспективу «Web». Сразу после импорта, скорее всего, раздел Java Resources будет помечен как содержащий ошибку, а окне Problems будет написана причина:
Project configuration is not up-to-date with pom.xml. Run project configuration update
Последуем данному совету: выделяем корень проекта, вызываем контекстное меню Maven -> Update Project Configuration, выполняем, и ошибка исчезнет.
Развернем дерево проекта:
Как видно, тут уже есть папки для java классов и ресурсов, а также в папке webapp файлы index.html, пара jsp-файлов, каталог WEB-INF с дескрипторами. Файл health.jsp можно сразу же удалить (а также описание сервлета health из дескриптора web.xml), зачем он здесь — непонятно. Файл snoop.jsp еще может пригодиться, в нем выводится кое-какая статистика о нашем приложении.
В корне проекта лежит pom.xml с единственной зависимостью
<dependency>
<groupId>org.jboss.spec</groupId>
<artifactId>jboss-javaee-6.0</artifactId>
<version>1.0.0.Final</version>
<type>pom</type>
<scope>provided</scope>
</dependency>
Это дает нам доступ ко всем включенным в jboss модулям (ознакомиться со всем списком можно, развернув ветку Libraries — Maven Dependencies.
Настройка конфигурации сервера
Теперь нам понадобится файл, который не был импортирован Eclipse. Он находится в каталоге проекта по адресу .openshift/config/standalone.xml, и, как видно из названия, описывает конфигурацию экземпляра сервера jboss. Откроем его тут же, в Eclipse (если приложение будет отлаживаться на локальном сервере jboss, придется подобные манипуляции выполнить с файлом в папке сервера standalone/configuration/standalone.xml).
Настройка кодировки
Для работы с русскими символами в базе данных соединение должно осуществляться в кодировке UTF-8. Поэтому найдем источник данных (в данном случае MysqlDS) и добавим сведения о кодировке:
<connection-url>jdbc:mysql://${env.OPENSHIFT_DB_HOST}:${env.OPENSHIFT_DB_PORT}/${env.OPENSHIFT_GEAR_NAME}?characterEncoding=UTF-8</connection-url>
Настройка модуля аутентификации
Теперь создадим домен безопасности, который назовем, например «app-auth». Необходимо найти подсистему «urn:jboss:domain:security:1.1» и добавить в нее описание нашего домена:
<security-domain name="app-auth">
<authentication>
<login-module code="org.jboss.security.auth.spi.DatabaseServerLoginModule" flag="required">
<module-option name="dsJndiName" value="java:jboss/datasources/MysqlDS"/>
<module-option name="principalsQuery" value="select PWD from APP_USER where USER_NAME=? and ENABLED=1"/>
<module-option name="rolesQuery" value="select r.ROLE_NAME, 'Roles' from APP_ROLE r, APP_MEMBERSHIP m, APP_USER u where r.ROLE_ID=m.ROLE_ID and m.USER_ID=u.USER_ID and u.USER_NAME=?"/>
<module-option name="hashAlgorithm" value="SHA-1"/>
<module-option name="hashEncoding" value="base64"/>
</login-module>
</authentication>
</security-domain>
Назначение свойств dsJndiName, principalsQuery, rolesQuery, думаю, очевидно. Последние 2 свойства говорят о том, что в базе будут храниться хеши паролей. Если эти свойства убрать, то пароли должны будут сохраняться в открытом виде, что допустимо при отладке, но с реальными данными делать не стоит.
Настройка приложения: Faces, безопасность, инициализация
<!-- JSF mapping -->
<servlet>
<servlet-name>Faces Servlet</servlet-name>
<servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>Faces Servlet</servlet-name>
<url-pattern>*.xhtml</url-pattern>
</servlet-mapping>
<!-- security -->
<login-config>
<auth-method>FORM</auth-method>
<realm-name>app-auth</realm-name>
<form-login-config>
<form-login-page>/login.xhtml</form-login-page>
<form-error-page>/login.xhtml</form-error-page>
</form-login-config>
</login-config>
<security-role>
<role-name>Admin</role-name>
</security-role>
<security-role>
<role-name>Manager</role-name>
</security-role>
<security-constraint>
<web-resource-collection>
<web-resource-name>Admin Part</web-resource-name>
<url-pattern>/admin/*</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
</web-resource-collection>
<auth-constraint>
<role-name>Admin</role-name>
</auth-constraint>
</security-constraint>
<security-constraint>
<web-resource-collection>
<web-resource-name>All Users</web-resource-name>
<url-pattern>/view/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>*</role-name>
</auth-constraint>
<user-data-constraint>
<transport-guarantee>NONE</transport-guarantee>
</user-data-constraint>
</security-constraint>
<!-- инициализация -->
<listener>
<listener-class>my.app.jaas.Initializer</listener-class>
</listener>
- JSF mapping — будем использовать xhtml формат страниц
- Настройка безопасности: ссылаемся на серверный логин-модуль, настроенный ранее; аутентификация с использованием формы; далее определяем 2 роли и назначаем пути к защищаемым ресурсам
- Инициализация — определяем класс, код которого должен быть выполнен при старте приложения. Тут мы сможем создать необходимые записи в базе данных (при первом запуске в базе должен быть создан пользователь с ролью администратора)
Настройка Maven: дополнительные зависимости в pom.xml
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>4.0.1.Final</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.richfaces.core</groupId>
<artifactId>richfaces-core-impl</artifactId>
<version>4.2.2.Final</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.richfaces.ui</groupId>
<artifactId>richfaces-components-ui</artifactId>
<version>4.2.2.Final</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.6</version>
</dependency>
- hibernate — необязательная зависимость, просто немного читерства, а в принципе можно обойтись возможностями JPA
- richfaces — большой набор компонент, расширенная поддержка ajax, несколько готовых скинов, встроенная библиотека jQuery, короче, облегчение жизни при программировании клиентской части. Можно заменить на IceFaces, PrimeFaces или любую другую понравившуюся библиотеку.
- commons-codec — понадобится для кодирования хешей в base64
Настройка Java Persistence
Добавим в проект JPA. Для этого откроем свойства проекта и найдем раздел Project Facets, в данном разделе надо поставить галочку напротив JPA. Будет автоматически создан файл persistence.xml. Далее можно настроить доступ к базе в этом файле, а можно передать настройку в hibernate.cfg.xml. Я предпочитаю второе, так как в этом случае под рукой оказывается удобный графический интерфейс, а также есть возможность сделать reverse engineering из существующей базы.
Для второго способа необходимо:
— в persistence.xml сослаться на hibernate.cfg.xml:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.0"
xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
<persistence-unit name="test">
<properties>
<property name="hibernate.ejb.cfgfile" value="/hibernate.cfg.xml" />
</properties>
</persistence-unit>
</persistence>
— в папку src/main/resources добавить файл hibernate.cfg.xml следующего содержания:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory name="">
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="hibernate.format_sql">true</property>
<property name="hibernate.show_sql">false</property>
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.connection.datasource">java:jboss/datasources/MysqlDS</property>
</session-factory>
</hibernate-configuration>
Обратите внимание на hibernate.hbm2ddl.auto: значение update позволяет автоматически обновлять схему данных, чтобы она соответствовала модели, и нам не придется писать ни строчки DDL для этой базы!
format_sql и show_sql могут пригодиться при отладке;
Закладка редактора «Session Factory» предоставляет еще кучу настроек, но в пока они не понадобятся.
На этом настройку можно считать завершенной.
Модель данных
Данные описываются 2 классами. Связь много-ко-многим описывается с обеих сторон множествами. Хозяином связи будет AppUser (AppRole редко изменяется, это скорее справочник, чем сущность).
Поскольку в MySql отсутствуют последовательности и автоинкремент, для генератора выбрана стратегия GenerationType.TABLE. Остальное, думаю, понятно из аннотаций.
import java.util.*;
import javax.persistence.*;
@ Entity
@ Table(name = "APP_USER", uniqueConstraints = @ UniqueConstraint(columnNames = "USER_NAME"))
public class AppUser implements java.io.Serializable {
private Long userId;
private String userName;
private String displayName;
private String pwd;
private Boolean enabled;
private Set<AppRole> roles = new HashSet<AppRole>(0);
@ TableGenerator(
name = "UserIdGen",
table = "APP_GEN",
pkColumnName = "GEN_NAME",
pkColumnValue = "USER_ID",
valueColumnName = "GEN_VAL",
allocationSize = 10)
@ Id
@ Column(name = "USER_ID", nullable = false)
@ GeneratedValue(strategy=GenerationType.TABLE, generator="UserIdGen")
public Long getUserId() {
return this.userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
@ Column(name = "USER_NAME", nullable = false, length = 30)
public String getUserName() {
return this.userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
@ Column(name = "DISPLAY_NAME", length = 250)
public String getDisplayName() {
return this.displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
@ Column(name = "PWD", length = 30)
public String getPwd() {
return this.pwd;
}
public void setPwd(String pwd) {
this.pwd = pwd;
}
@ Column(name = "ENABLED")
public Boolean getEnabled() {
return this.enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
@ ManyToMany(fetch = FetchType.LAZY, cascade=CascadeType.ALL)
@ JoinTable(
name = "APP_MEMBERSHIP",
joinColumns = {
@ JoinColumn(name = "USER_ID", nullable = false, updatable = false)
},
inverseJoinColumns = {
@ JoinColumn(name = "ROLE_ID", nullable = false, updatable = false)
})
public Set<AppRole> getRoles() {
return this.roles;
}
public void setRoles(Set<AppRole> roles) {
this.roles = roles;
}
}
Для пароля указана длина 30: этого должно хватить для SHA-1 дайджеста (20 байт) в кодировке base64;
Для поля enabled указан тип Boolean, не всякий сервер это поймет (например, в FirebirdSQL придется создать домен с таким именем), но MySql его интерпретирует без вопросов.
import java.util.*;
import javax.persistence.*;
@ Entity
@ Table(name = "APP_ROLE", uniqueConstraints = @ UniqueConstraint(columnNames = "ROLE_NAME"))
public class AppRole implements java.io.Serializable {
private Long roleId;
private String roleName;
private String displayName;
private Set<AppUser> users = new HashSet<AppUser>(0);
@ TableGenerator(
name = "RoleIdGen",
table = "APP_GEN",
pkColumnName = "GEN_NAME",
pkColumnValue = "ROLE_ID",
valueColumnName = "GEN_VAL",
allocationSize = 10)
@ Id
@ Column(name = "ROLE_ID", nullable = false)
@ GeneratedValue(strategy=GenerationType.TABLE, generator="RoleIdGen")
public Long getRoleId() {
return this.roleId;
}
public void setRoleId(Long roleId) {
this.roleId = roleId;
}
@ Column(name = "ROLE_NAME", length = 30)
public String getRoleName() {
return this.roleName;
}
public void setRoleName(String roleName) {
this.roleName = roleName;
}
@ Column(name = "DISPLAY_NAME", length = 250)
public String getDisplayName() {
return this.displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
@ ManyToMany(fetch = FetchType.LAZY, mappedBy = "roles")
public Set<AppUser> getUsers() {
return this.users;
}
public void setUsers(Set<AppUser> users) {
this.users = users;
}
}
При желании в проект также можно добавить класс AppGen, который будут соответствовать таблице генераторов APP_GEN, для того, чтобы наше приложение могло работать с устаревшими (legacy) SQL серверами. Дело в том, что по умолчанию в таблице APP_GEN будет создано поле — первичный ключ GEN_NAME длиной 256 символов, что не всегда поддерживается, и эту длину можно уменьшить, явно указав в аннотации. По мне так достаточно и 30 символов (см.например длину названий последовательностей в Oracle).
Инициализация приложения
Инициализацию приложения будет выполнять тот самый класс my.app.jaas.Initializer, который был ранее указан в web.xml
@ ManagedBean
public class Initializer implements ServletContextListener {
private static final Logger log = Logger.getLogger(Initializer.class);
@Override
public void contextDestroyed(ServletContextEvent event) {}
@Override
public void contextInitialized(ServletContextEvent event) {
loadData();
}
@ PersistenceContext
EntityManager em;
private AppRole checkRole(String roleName, String displayName, Session session) {
AppRole role =
(AppRole)session.createCriteria(AppRole.class)
.add(Restrictions.eq("roleName", roleName))
.uniqueResult();
if (role == null) {
role = new AppRole();
role.setRoleName(roleName);
role.setDisplayName(displayName);
session.save(role);
}
return role;
}
private void loadData() {
Session session = (Session) em.getDelegate();
AppRole adminRole = checkRole("Admin", "Администраторы", session);
checkRole("Manager", "Менеджеры", session);
if (adminRole.getUsers().size()==0) {
AppUser user =
(AppUser)session.createCriteria(AppUser.class)
.add(Restrictions.eq("userName", "admin"))
.uniqueResult();
if(user==null) {
user = new AppUser();
user.setUserName("admin");
user.setDisplayName("Администратор");
user.setPwd(encode("topsecret"));
user.setEnabled(true);
session.save(user);
}
adminRole.getUsers().add(user);//nothing
user.getRoles().add(adminRole);
session.save(adminRole);
session.save(user);
}
session.flush();
session.close();
}
public static String encode(String value) {
//get the message digest
try{
MessageDigest md = MessageDigest.getInstance("SHA"); //SHA-1 algorithm
md.update(value.getBytes("UTF-8")); //byte-representation using UTF-8 encoding format
byte raw[] = md.digest();
String hash = Base64.encodeBase64String(raw).trim();
return hash;
} catch(Exception e) {
log.error(e, e);
}
return value;
}
public String logout() {
FacesContext ctx = FacesContext.getCurrentInstance();
HttpSession session = (HttpSession)ctx.getExternalContext().getSession(false);
session.invalidate();
return("logout");
}
}
Как видно, реализован единственный метод-слушатель ServletContextListener.contextInitialized, в котором проверяются и при необходимости создаются роли, а также проверяется наличие хотя бы 1 администратора. При отсутствии администратора создается учетная запись admin.
Статический метод encode можно будет использовать в модуле управления пользователями.
Также нам пригодится еще 1 метод logout(), с очевидным назначением.
Работа с базой данных в данном случае ведется не через JPA, а на уровень ниже — через hibernate API, в результате можно использовать замечательный интерфейс org.hibernate.Criteria и выполнить все действия без единой строчки на sql, hql или jpql.
Форма аутентификации
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:a4j="http://richfaces.org/a4j"
xmlns:rich="http://richfaces.org/rich"
xmlns:f="http://java.sun.com/jsf/core"
xmlns:ui="http://java.sun.com/jsf/facelets"
xmlns:c="http://java.sun.com/jsp/jstl/core"
xmlns:h="http://java.sun.com/jsf/html">
<h:head>
<title>Вход в систему</title>
<h:outputStylesheet>
div.login-container {
width: 255px;
position: relative;
margin: 0 auto 0 auto;
}
</h:outputStylesheet>
</h:head>
<h:body>
<div class="login-container" id="login_container">
<rich:panel>
<f:facet name="header">
<h:outputText value="Вход в систему" />
</f:facet>
<form method="post" action="j_security_check" name="loginform"
id="loginForm" target="_parent">
<h:panelGrid columns="2" cellpadding="2"
columnClasses="right,left" width="100%">
<h:outputLabel for="j_username" value="Логин:" />
<h:inputText style="width: 155px;"
id="j_username" value="" />
<h:outputLabel for="j_password" value="Пароль:" />
<h:inputSecret style="width: 155px;"
id="j_password" value="" />
<h:panelGroup />
<h:panelGroup />
<h:panelGroup />
<h:panelGroup>
<h:commandButton name="login" id="login-submit" value="Вход" />
<h:outputText value=" " escape="false"/>
<h:commandButton type="button" id="login-cancel"
value="Отмена" />
</h:panelGroup>
</h:panelGrid>
</form>
</rich:panel>
</div>
<h:outputScript>
(function(){
jQuery("#login_container").offset({top:Math.max(0,(jQuery(window).height()/2)-150)});
var el = jQuery("#j_username").get(0);el.focus();el.select();
})();
</h:outputScript>
</h:body>
</html>
Тут можно рисовать любую форму, единственное требование — на сервер должны сабмититься значения j_username и j_password. Поскольку в данном случае используются компоненты richfaces, то в код страницы автоматически включается jQuery, возможности которого и используются в скрипте для позиционирования контейнера login-container и автоматического выделения элемента с именем пользователя.
Итак, все готово для первого запуска. Далее помещаем любой контент в каталоги webapp/view, webapp/admin, коммитим изменения на сервер, и после запуска приложения убеждаемся, что доступ в эти каталоги возможен только после аутентификации и при наличии соответствующих ролей.
При старте приложения в базе данных будут автоматически созданы необходимые таблицы и записи, в этом можно убедиться установив картридж phpmyadmin, либо включив трассировку запросов в файле hibernate.cfg.xml:
<property name="hibernate.show_sql">false</property>
Выводы
На приведенном выше примере была рассмотрена разработка приложения с аутентификацией для OpenShift. Это же приложение можно скомпилировать и использовать на любом другом сервере JBoss AS 7.1 и с любым из поддерживаемых sql диалектов. Различие будет только в расположении файла настройки standalone.xml, и в необходимости установки нужного jdbc модуля.
При настройке подключения к источнику данных следует помнить о кодировке.
В рассмотренном шаблоне использовался минимум подгружаемых библиотек, что немаловажно для ограниченных ресурсов, предоставляемых OpenShift Express. В основном используются модули, уже включенные в дистрибутив JBoss, как результат — экономия дискового пространства и времени публикации приложения.
Автор: ivanra