пятница, 7 июня 2013 г.

Изменение стилей Tapestry приложения

В данной статье описывается, как поменять стили у программы, написанной с использованием Tapestry 5.3.x

Добавляем нашу любимую библиотечку со стилями, например bootstrap.css. Это почти сразу придаст приложению определенный общий вид, как будто для него разрабатывали дизайн. Но к сожалению, некоторые вещи, в частности валидация, пейджинг, таблицы, начинают смотреться чужеродно. Например вот так выглядит страница логина после применения bootstrap.css
К сожалению, исправить там что-то уже довольно сложно.
Поэтому принимаем решение полностью отказаться от стилей tapestry в приложении. Делается это следующим образом: добавляем к AppModule.java вот такой код
    @Contribute(MarkupRenderer.class)
    public static void deactiveDefaultCSS(OrderedConfiguration<MarkupRendererFilter> configuration)
    {
        configuration.override("InjectDefaultStylesheet", null);
    }
  

После удаления таблицы стилей страницы выглядят пустовато; но ничего, скоро мы это исправим.
Добавляем style.css - нашу собственную таблицу стилей. В ней мы переопределим некоторые стили tapestry.

Для начала, добавим стили для корректного отображения ошибок:

#корректируем ошибку tapestry с заползанием текста ошибки за левый край
DIV.t-error LI{
  margin-left: 20px;
}

#задаем цвет попапа с ошибкой
DIV.t-error-popup{
   color: #b94a48;
}

#рисуем стрелку вниз
.t-error-popup > span:after {
    border-color: #fcf8e3 transparent;
    border-style: solid;
    border-width: 15px 15px 0;
    bottom: 0px;
    content: "";
    display: block;
    left: 20px;
    position: absolute;
    width: 0;
}

#рисуем попап
DIV.t-error-popup > span {
   padding: 4px;
   text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
   border-color: #eed3d7;
   background-color: #fcf8e3;
   border: 1px solid #eed3d7;
   -webkit-border-radius: 4px;
      -moz-border-radius: 4px;
           border-radius: 4px;
}

#Задаем цвет контура поля ввода с ошибкой
input.t-error{
      border-color: #eed3d7;
}


И стили для корректного отображения pager'а

#стили для grid pager, скопированы из bootstrap.css
DIV.t-data-grid-pager{
    padding-left: 15px;
    display: inline-block;
  *display: inline;
  margin-bottom: 4px;
  margin-left: 0;
  -webkit-border-radius: 4px;
     -moz-border-radius: 4px;
          border-radius: 4px;
  *zoom: 1;
}
.t-data-grid-pager > a,
.t-data-grid-pager > span {
  padding: 4px 12px;
  line-height: 20px;
  text-decoration: none;
  background-color: #ffffff;
  border: 1px solid #dddddd;
  /*border-left-width: 0;*/
}
.t-data-grid-pager > a:hover,
.t-data-grid-pager > a:focus,
.t-data-grid-pager > .current > a,
.t-data-grid-pager > .current > span {
  background-color: #f5f5f5;
}
.t-data-grid-pager > .current > a,
.t-data-grid-pager > .current > span {
  color: #999999;
  cursor: default;
}
.t-data-grid-pager a:first-child,
.t-data-grid-pager span:first-child {
  border-left-width: 1px;
  -webkit-border-bottom-left-radius: 4px;
          border-bottom-left-radius: 4px;
  -webkit-border-top-left-radius: 4px;
          border-top-left-radius: 4px;
  -moz-border-radius-bottomleft: 4px;
  -moz-border-radius-topleft: 4px;
}
.t-data-grid-pager a:last-child,
.t-data-grid-pager span:last-child  {
  -webkit-border-top-right-radius: 4px;
          border-top-right-radius: 4px;
  -webkit-border-bottom-right-radius: 4px;
          border-bottom-right-radius: 4px;
  -moz-border-radius-topright: 4px;
  -moz-border-radius-bottomright: 4px;
}

Результат:



Подправим beandisplay

DL.t-beandisplay {
    background: none repeat scroll 0 0 rgb(245, 245, 245);
    display: block;
    padding: 2px;
    width: auto;
}
DL.t-beandisplay DT:after {
    content: ":";
}
DL.t-beandisplay DT {
    clear: left;
    display: inline;
    float: left;
    padding-right: 3px;
    text-align: right;
    vertical-align: middle;
    width: 250px;
}


Поправим показ таблиц (выделим заголовок)
.table th {
  white-space: nowrap;
  line-height: 20px;
  background-color: rgb(245, 245, 245);
}


Осталось немного допилить тексты сообщений, выводимые Tapestry при срабатывании валидации. Чтобы их скастомизировать, необходимо создать следующие файлы:
org.apache.tapestry5.corelib.components.Errors.properties следующего содержания:
default-banner=Вам необходимо исправить следующее, чтобы продолжить:
И org.apache.tapestry5.internal.ValidationMessages.properties в котором можно переопределить сообщения, выдаваемые при различных видах валидации, например для required:
required=Укажите значение

Результат:



четверг, 6 июня 2013 г.

Организация security в приложениях, написанных с использованием Tapesrty5

Существует несколько различных способов обеспечения безопасности в приложениях на Tapestry 5. В данной статье описывается только один из них - security с использованием библиотеки shiro и прослойки, которая осуществляет интеграцию с этой библиотекой - tapestry-security

При использовании maven необходимо добавить в зависимости:

        <dependency>
            <groupId>org.tynamo</groupId>
            <artifactId>tapestry-security</artifactId>
            <version>0.4.6</version>
        </dependency>


Самый простой случай, когда пользователи и пароли хранятся в файле, рассматривать не будем, это сделано в документации, вместо этого создадим свой собственный Realm. Ограничимся разделением доступа по ролям, с предопределенным набором ролей.
Для начала создадим класс пользователя User, экземпляр этого класса будет использоваться как SecurityPrincipal в дальнейшем.

import org.apache.shiro.crypto.SecureRandomNumberGenerator;
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.util.ByteSource;
import javax.persistence.*;


@Entity
@Table(name = "TEST_USER")
public class User implements Serializable{
    public static final String ROLE_ADMIN = "admin", ROLE_OPERATOR = "operator";
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false, length = 100)
    private String login;
    @Column(nullable = false)
    private String name;
    @Column(nullable = true)
    private String passwordHash;
    @Column(nullable = false)
    private boolean enabled = true;
    @Column(length = 128)
    private byte[] salt;
    @Column(nullable = false)
    private boolean admin = false;
   @Override
    public String toString() {
        return name;
    }
    public void setPassword(String password){
        ByteSource saltSource = new SecureRandomNumberGenerator().nextBytes();
        this.setSalt(saltSource.getBytes());
        this.setPasswordHash(new
Sha256Hash(password, saltSource).toString());
    }
}


Тривиальные get/set методы опущены. Как можно заметить, пароль не хранится в открытом виде, хранится только SHA-256 хэш пароля и соль.

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

import org.apache.shiro.authc.*;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.cache.MemoryConstrainedCacheManager;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.SimpleByteSource;
import org.example.test.AppContext;
import
org.example.test.domain.User;

import java.util.HashSet;
import java.util.Set;

public class UserRealm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        if(principalCollection.isEmpty()) return null;
        try{
            User user = (User)principalCollection.fromRealm(getName()).iterator().next();
            if (user == null) return null;
            Set<String> roles = new HashSet<String>();

            // здесь используются только две роли, но ничто не мешает пользователю иметь несколько ролей одновременно
            if(user.isAdmin()){
                roles.add(User.ROLE_ADMIN);
            }else{
                roles.add(User.ROLE_OPERATOR);
            }
            return new SimpleAuthorizationInfo(roles);
        }catch (Exception ex){
            ex.printStackTrace();
            return null;
        }
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        // Null username is invalid
        if (username == null) { throw new AuthenticationException("Void username are not allowed by this realm."); }

        // запрос на получение пользователя из базы данных
        User user = AppContext.getUserDao().findByLogin(username);
        if(user == null){
            throw new UnknownAccountException("user not found");
        }
        return new SimpleAuthenticationInfo(user, user.getPasswordHash(), new SimpleByteSource(user.getSalt()), getName());
    }

    public UserRealm() {
        super(new MemoryConstrainedCacheManager());
        setName("dbaccounts");
        setAuthenticationTokenClass(UsernamePasswordToken.class);
        HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
        matcher.setHashAlgorithmName("SHA-256");
        setCredentialsMatcher(matcher);
    }
}


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

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

import org.apache.shiro.realm.Realm;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.tapestry5.ioc.Configuration;
import org.apache.tapestry5.ioc.MappedConfiguration;
import org.apache.tapestry5.ioc.annotations.Contribute;
import org.apache.tapestry5.ioc.annotations.Marker;
import org.apache.tapestry5.ioc.services.ApplicationDefaults;
import org.apache.tapestry5.ioc.services.SymbolProvider;
import org.apache.tapestry5.services.HttpServletRequestFilter;
import org.tynamo.security.Security;
import org.tynamo.security.SecuritySymbols;
import org.tynamo.security.services.SecurityFilterChainFactory;
import org.tynamo.security.services.impl.SecurityFilterChain;
import org.example.test.domain.User;


    @Contribute(WebSecurityManager.class)
    public static void addRealms(Configuration<Realm> configuration) {
        UserRealm ur = new UserRealm();
        configuration.add(ur);
    }

    @Contribute(SymbolProvider.class)
    @ApplicationDefaults
    public static void applicationDefaults(MappedConfiguration<String, Object> configuration){
        // Tynamo's tapestry-security (Shiro) module configuration
        configuration.add(SecuritySymbols.LOGIN_URL, "/login");
        configuration.add(SecuritySymbols.SUCCESS_URL, "/index");
        configuration.add(SecuritySymbols.UNAUTHORIZED_URL, "/accessDenied");
    }

    @Contribute(HttpServletRequestFilter.class)
    @Marker(Security.class)
    public static void contributeSecurityConfiguration(Configuration<SecurityFilterChain> configuration,
                                                       SecurityFilterChainFactory factory,
                                                       WebSecurityManager securityManager) {
        configuration.add(factory.createChain("/admin/**").add(factory.roles(), User.ROLE_ADMIN).build());
        configuration.add(factory.createChain("/operator/**").add(factory.roles(), User.ROLE_OPERATOR).build());
        configuration.add(factory.createChain("/css/**").add(factory.anon()).build());
        configuration.add(factory.createChain("/*").add(factory.anon()).build());
    }


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

import org.apache.shiro.authc.*;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.StringUtils;
import org.apache.shiro.web.util.SavedRequest;
import org.apache.shiro.web.util.WebUtils;
import org.apache.tapestry5.PersistenceConstants;
import org.apache.tapestry5.annotations.Persist;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.services.RequestGlobals;
import org.apache.tapestry5.services.Response;
import org.tynamo.security.services.PageService;
import org.tynamo.security.services.SecurityService;
import java.io.IOException;

public class Login {

    @Property
    private String jsecLogin;
    @Property
    private String jsecPassword;
    @Property
    private boolean jsecRememberMe;
    @Persist(PersistenceConstants.FLASH)
    private String loginMessage;
    @Inject
    private Response response;
    @Inject
    private RequestGlobals requestGlobals;
    @Inject
    private SecurityService securityService;
    @Inject
    private PageService pageService;

    public Object onActionFromJsecLoginForm() {
        Subject currentUser = securityService.getSubject();
        if (currentUser == null) {
            throw new IllegalStateException("Subject cannot be null");
        }
        UsernamePasswordToken token = new UsernamePasswordToken(jsecLogin, jsecPassword);
        token.setRememberMe(jsecRememberMe);
        try {
            currentUser.login(token);
        } catch (UnknownAccountException e) {
            loginMessage = "Такого пользователя нет в базе";
            return null;
        } catch (IncorrectCredentialsException e) {
            loginMessage = "Неверный пароль";
            return null;
        } catch (LockedAccountException e) {
            loginMessage = "Пользователь заблокирован";
            return null;
        } catch (AuthenticationException e) {
            loginMessage = "Ошибка авторизации";
            return null;
        }
        SavedRequest savedRequest = WebUtils.getAndClearSavedRequest(requestGlobals.getHTTPServletRequest());
        if (savedRequest != null) {
            try {
                WebUtils.redirectToSavedRequest(requestGlobals.getHTTPServletRequest(), requestGlobals.getHTTPServletResponse(), pageService.getSuccessPage());
                return savedRequest.getRequestURI();
            } catch (IOException e) {
                return pageService.getSuccessPage();
            }
        } else {
            return pageService.getSuccessPage();
        }
    }
    public String getLoginMessage() {
        if (hasLoginMessage()) {
            return loginMessage;
        } else {
            return " ";
        }
    }
    public boolean hasLoginMessage() {
        return StringUtils.hasText(loginMessage);
    }
}


Login.tml В файле layout'а прописаны ссылки на bootstrap.css, и не используется css от tapestry. Как сделать красивые странички, и сколько проблем при этом возникло - тема для отдельного поста.

<t:layout title="literal:Вход" bodyId="signin"
          xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd"
          xmlns:p="tapestry:parameter">
    <t:form class="form-horizontal" t:id="jsecLoginForm">
        <t:if test="hasLoginMessage()">
        <div class="control-group">
            <div class="controls">
            <label class="text-error">${loginMessage}</label>
            </div>
        </div>
        </t:if>
        <div class="control-group">
            <label class="control-label" for="jsecLogin">Логин</label>
            <div class="controls">
                <t:textfield t:id="jsecLogin" class="input-large" validate="required" size="40" tabindex="1"  placeholder="Login"/>
            </div>
        </div>
        <div class="control-group">
            <label class="control-label" for="jsecPassword">Пароль</label>
            <div class="controls">
                <t:passwordfield t:id="jsecPassword" class="input-large" validate="required" size="40" tabindex="2" placeholder="Password" />
            </div>
        </div>
        <div class="control-group">
            <div class="controls">
                <t:pageLink page="getPass">Забыли пароль?</t:pageLink>
                <label class="checkbox">
                    <t:checkbox t:id="jsecRememberMe" tabindex="3" /> Запомнить меня
                </label>
                <input type="submit" name="login" class="btn" value="Войти" tabindex="4" />
            </div>
        </div>
    </t:form>
</t:layout>


После успешного логина пользователя пребрасывает на index, где уже осуществляется переброс в засимости от роли.

public class Index {
    @InjectPage
    private AdminIndex adminIndex;
    @InjectPage
    private OperatorIndex operatorIndex;
    @OnEvent(value = EventConstants.ACTIVATE)
    public Object initPage() {
        if(SecurityUtils.getSubject().hasRole(User.ROLE_ADMIN)){
            return adminIndex;
        }else{
            return operatorIndex;
        }
    }
}


И остался небольшой момент - это logout. Функция logout реализована непосредственно в шаблоне

public class Layout {

    @InjectPage
    private Index index;
    @Inject
    @Property
    private HttpServletRequest request;
    public String getUsername() {
        try {

            return SecurityUtils.getSubject().getPrincipal().toString();
        } catch (NullPointerException ex) {
            return "guest";
        }
    }

    public Object onActionFromLogout() {
        SecurityUtils.getSecurityManager().logout(SecurityUtils.getSubject());
        try {
            final HttpSession session = this.request.getSession(false);
            if (session != null) {
                session.invalidate();
            }
        } catch (final IllegalStateException ex) { }
        return index;
    }
}


И Layout.tml

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd"
      xmlns:p="tapestry:parameter" >
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <link href="${request.contextPath}/css/bootstrap.css" rel="stylesheet" type="text/css"/>
    <link href="${request.contextPath}/css/mystyle.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<t:security.user>
    <p class="pad-left">Здравствуйте, ${username}

    <t:actionLink t:id="logout" class="pull-right pad-right"> Выйти</t:actionLink></p>
</t:security.user>
<div class="control-group">
    <div class="control">
        <t:body/>
    </div>
</div>
</body>
</html>


В общем-то этого должно быть достаточно. Если необходимо получить где-то в коде текущего залогиненного пользователя, достаточно выполнить (org.example.test.domain.User) SecurityUtils.getSubject().getPrincipal();