huruyosi’s blog

プログラミングとかインフラとかのメモです。

spring boot その7 - spring security で 認証を行う

実装したソースは https://github.com/huruyosiathatena/springboot/tree/8bb72d41f62d28039f5c7d0ccda8f4036ecea8ca にあります。

ログインを実装する

spring security を利用してログイン画面を作ります。今回は認証を行うだけで権限に基づいた制御は次回( http://huruyosi.hatenablog.com/entry/2015/08/10/015844 )で行います。

認証に利用するユーザ情報

DBに保存されているテーブルを利用します。

create table users (
  user_id int auto_increment,
  login_id varchar(256),
  password varchar(256),
  display_name varchar(255),
  enabled boolean,
  primary key(id)
);

create table authorities (
  user_id int,
  authority varchar(256),
  FOREIGN KEY (user_id) REFERENCES users(user_id)
);

usersテーブル

列名 意味
user_id 主キー
login_id ログイン画面に入力するID
password パスワード。べたな状態。ハッシュ化次の次
display_name ユーザの表示名
enabled 1なら有効

authorities テーブル

列名 意味
login_id 権限を許可されたユーザのID
authority 許可された権限名

データ投入の方法

src/main/resources/schema.sql と src/main/resources/data.sql を作成します。 schema.sqlにはDDLを記載し、data.sql にはデータ投入のDMLを記載します。アプリ起動時に spring boot が SQLスクリプトを実行します。

pom.xml

spring security と一緒にDBアクセスを行うためにJPA 及び h2Databaseのドライバーを追加します。

articact-id thymeleaf-extras-springsecurity3 は テンプレートで認証情報を参照するために追加しました。  ${#authentication.principal} と書くと MyUserDetail のインスタンスを得ることができます。

     <!-- JPA Database -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

        <!-- security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity3</artifactId>
        </dependency>

src/main/resources/application.yml

DBの接続先を定義し、spring securiyのBASIC認証を無効にします。

spring:
  secuirt:
    basic.enable: false

  datasource:
    url: jdbc:h2:mem:sbadmin2;MODE=MYSQL
    username: sa
    password:
    driverClassName: org.h2.Driver

  jpa:
    hibernate.ddl-auto: none
    database-platform: org.hibernate.dialect.H2Dialect

DBアクセスを実装

com.hatenablog.huruyosi.springboot.entity.Users

usersテーブルのエンティティを実装します。

@Entity
@Table(name="users")
public class Users {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    protected Integer id;

    @Column(name="login_id")
    protected String loginId;

    protected String name;

    protected String password;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    protected List<Authorities> authorities;


    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public List<Authorities> getAuthorities() {
        return authorities;
    }

    public void setAuthorities(List<Authorities> authorities) {
        this.authorities = authorities;
    }


    public String getLoginId() {
        return loginId;
    }

    public void setLoginId(String loginId) {
        this.loginId = loginId;
    }

    /**
    * {@link UserDetails}に変換します。
    * @return
    */
    public UserDetails toMyUserDetail() {
        return MyUserDetail.create(this);
    }

}

com.hatenablog.huruyosi.springboot.entity.Authorities

authoritiesテーブルのエンティティです。

@Entity
@Table(name="authorities")
public class Authorities {

    @Id
    private com.hatenablog.huruyosi.springboot.entity.type.AuthoritiesId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", insertable = false, updatable = false)
    private Users user;

    public Authorities() {
    }

    /** コンストラクタ */
    public Authorities(AuthoritiesId id){
        this.id = id;
    }

    public AuthoritiesId getId() {
        return id;
    }

    public void setId(AuthoritiesId id) {
        this.id = id;
    }

    public Users getUser() {
        return user;
    }

    public void setUser(Users user) {
        this.user = user;
    }

}

com.hatenablog.huruyosi.springboot.entity.type.AuthoritiesId

authoritiesテーブルが複合主キーなので 主キーのクラスを作成します。

@Embeddable
public class AuthoritiesId implements Serializable {

    private int user_id;
    private String authority;

    public AuthoritiesId() {
    }

    /** コンストラクタ */
    public AuthoritiesId(int user_id, String authority) {
        super();
        this.user_id = user_id;
        this.authority = authority;
    }

    public int getUser_id() {
        return user_id;
    }
    public void setUser_id(int user_id) {
        this.user_id = user_id;
    }
    public String getAuthority() {
        return authority;
    }
    public void setAuthority(String authority) {
        this.authority = authority;
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result
                + ((authority == null) ? 0 : authority.hashCode());
        result = prime * result + user_id;
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        AuthoritiesId other = (AuthoritiesId) obj;
        if (authority == null) {
            if (other.authority != null)
                return false;
        } else if (!authority.equals(other.authority))
            return false;
        if (user_id != other.user_id)
            return false;
        return true;
    }

}

com.hatenablog.huruyosi.springboot.repository.UserRepository

usersのDAOを用意します。

@Repository
public interface UserRepository extends JpaRepository<Users, Integer>{

    /**
    * {@link Users#get
    * @param loginId
    * @return
    */
    public Users findByLoginId(String loginId);
}

認証処理

認証に必要な処理を WebSecurityConfigurerAdapter を継承した WebSecuirty クラスに実装します。

ApplicationSecurity#configure(HttpSecurity http) メソッド

ログインとログアウトの設定を行います。

意味
.antMatchers("/sbadmin2/css/").permitAll().antMatchers("/sbadmin2/js/").permitAll().antMatchers("/sbadmin2/bower_components/**").permitAll() cssjavascript関係を認証状態に関係なく許可する
loginPage("/login") ログイン画面のパスは /login
usernameParameter("userid") ログインフォームのログインIDが設定されているinputタグのname属性の値
passwordParameter("password") ログインフォームのパスワードが設定されているinputタグのname属性の値
defaultSuccessUrl("/sbadmin2/index.html") ログイン成功後のリダイレクトのパスは/sbadmin2/index.html"
failureUrl("/login?error") ログインに失敗した時のパスは/login?error
logoutRequestMatcher(new AntPathRequestMatcher("/logout")) ログアウト画面のURLは/logout
logoutSuccessUrl("/login?logout") ログアウトに成功した時のリダイレクト先のパスは/login?logout
deleteCookies("JSESSIONID") ログアウトしたら cookieの JSESSIONID を削除
invalidateHttpSession(true) ログアウトしたらセッションを無効にする

configAuthentication(AuthenticationManagerBuilder auth) メソッド

認証を行う org.springframework.security.core.userdetails.UserDetailsServiceを実装したクラスを指定します。

com.hatenablog.huruyosi.springboot.config.WebSecuirty

package com.hatenablog.huruyosi.springboot.config;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import com.hatenablog.huruyosi.springboot.service.JdbcUserDetailsServiceImpl;

@Configuration
@EnableWebMvcSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled=true)
public class WebSecuirty extends WebSecurityConfigurerAdapter  {

    @Autowired
    private DataSource dataSource;

    @Autowired
    private JdbcUserDetailsServiceImpl userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/sbadmin2/css/**").permitAll()
                .antMatchers("/sbadmin2/js/**").permitAll()
                .antMatchers("/sbadmin2/bower_components/**").permitAll()
                .anyRequest().fullyAuthenticated()
        .and().formLogin()
                .loginPage("/login")
                .usernameParameter("userid")
                .passwordParameter("password")
                .defaultSuccessUrl("/sbadmin2/index.html")
                .failureUrl("/login?error").permitAll()
        .and().logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/login?logout")
                .deleteCookies("JSESSIONID")
                .invalidateHttpSession(true).permitAll();
    }

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.userDetailsService(userDetailsService);
    }
}

com.hatenablog.huruyosi.springboot.service.JdbcUserDetailsServiceImpl

org.springframework.security.core.userdetails.UserDetailsService を実装します。 loadUserByUsernameメソッドの引数には、ログイン画面で入力された ユーザIDが渡されるので、usersテーブルがlogin_idと入力されたユーザIDが一致するレコードを検索します。usersテーブルから検索して、レコードが無い場合には org.springframework.security.core.userdetails.UsernameNotFoundException を送出します。

usersテーブルにレコードがある場合には org.springframework.security.core.userdetails.UserDetailsを実装したクラスcom.hatenablog.huruyosi.springboot.service.MyUserDetailインスタンスを生成して返却します。

/**
 * DBを参照してユーザ情報を提供します。
 *
 */
@Component
public class JdbcUserDetailsServiceImpl implements UserDetailsService  {

    @Autowired
    private UserRepository userRepository;

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetailsService#loadUserByUsername(java.lang.String)
    */
    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        if ( username == null || username.isEmpty() ){
            throw new UsernameNotFoundException("username is empty");
        }

        Users foundUser = userRepository.findByLoginId(username);
        if( foundUser != null ){
            return foundUser.toMyUserDetail();
        }
        throw new UsernameNotFoundException( username + "is not found");
    }
}

com.hatenablog.huruyosi.springboot.service.MyUserDetail

spring securityが提供する org.springframework.security.core.userdetails.User がありますが、独自の属性を提供するために org.springframework.security.core.userdetails.UserDetailsを実装したクラスを作成します。

/**
 * 本システム用 のユーザ情報
 *
 */
public class MyUserDetail implements UserDetails {
    private int userId;
    private String username;
    private String password;
    private String displayName;
    private Collection<GrantedAuthority> authorities;


    public MyUserDetail(int userId, String username, String password,
            String displayName, Collection<GrantedAuthority> authorities) {
        super();
        this.userId = userId;
        this.username = username;
        this.password = password;
        this.displayName = displayName;
        this.authorities = authorities;
    }

    /**
    * {@link Users}を元にインスタンスを生成します。
    * @param user 生成元になるユーザ
    * @return
    */
    public static UserDetails create(Users entity) {
        List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
        for(Authorities auth: entity.getAuthorities()){
            authorities.add(new SimpleGrantedAuthority(auth.getId().getAuthority()));
        }
        return new MyUserDetail(entity.getId(), entity.getLoginId(), entity.getPassword(), entity.getName(), authorities);
    }


    /**
    * 永続化された{@link Users}の{@link Users#getId()}を取得します。
    * @return
    */
    public int getUserId(){
        return this.userId;
    }

    /**
    * 永続化された{@link Users}の{@link Users#getName()}を取得します。
    * @return
    */
    public String getDisplayName(){
        return this.displayName;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#getAuthorities()
    */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#getPassword()
    */
    @Override
    public String getPassword() {
        return this.password;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#getUsername()
    */
    @Override
    public String getUsername() {
        return this.username;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#isAccountNonExpired()
    */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#isAccountNonLocked()
    */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#isCredentialsNonExpired()
    */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /* (非 Javadoc)
    * @see org.springframework.security.core.userdetails.UserDetails#isEnabled()
    */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

src/main/java/com/hatenablog/huruyosi/springboot/controllers/LoginController.java

ログイン画面を表示するコントローラーです。

package com.hatenablog.huruyosi.springboot.controllers;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class LoginController {

    @RequestMapping(value="/login")
    public String login(){
        return "/SbAdmin2Controller/login";
    }
}

src/main/resources/templates/SbAdmin2Controller/login.html

SBAdmin2のテンプレートからの変更箇所 * formタグにmethod属性とth:action属性を追加 * <input name="email">のname属性を userid に変更 * エラーとログアウトのメッセージを表示する<div class="alert">を追加 * ログインボタンをaタグからinputタグに変更

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="sbadmin2ContentOnly">
<head>
</head>
<body>
  <div layout:fragment="content">
    <div class="container">
        <div class="row">
            <div class="col-md-4 col-md-offset-4">
                <div class="login-panel panel panel-default">
                    <div class="panel-heading">
                        <h3 class="panel-title">Please Sign In</h3>
                    </div>
                    <div class="panel-body">
                        <div class="alert alert-warning" role="alert" th:if="${param.logout}">You have been logged out</div>
                        <div class="alert alert-danger"  role="alert" th:if="${param.error}">There was an error, please try again</div>
                        <form role="form" method="post" th:action="@{/login}">
                            <fieldset>
                                <div class="form-group">
                                    <input class="form-control" placeholder="E-mail" name="userid" type="text" autofocus="" />
                                </div>
                                <div class="form-group">
                                    <input class="form-control" placeholder="Password" name="password" type="password" value="" />
                                </div>
                                <div class="checkbox">
                                    <label>
                                        <input name="remember" type="checkbox" value="Remember Me" />Remember Me
                                    </label>
                                </div>
                                <!-- Change this to a button or input when using this as a form -->
                                <input type="submit" value="login" class="btn btn-lg btn-success btn-block" />
                            </fieldset>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
  </div>
</body>

</html>

src/main/resources/templates/sbadmin2.html

  • ログアウトのaタグにhref属性を追加
  • User Profileのリンクにログインしているユーザ名を表示する
                <li class="dropdown">
                    <a class="dropdown-toggle" data-toggle="dropdown" href="#">
                        <i class="fa fa-user fa-fw"></i>  <i class="fa fa-caret-down"></i>
                    </a>
                    <ul class="dropdown-menu dropdown-user">
                        <li><a href="#"><i class="fa fa-user fa-fw"></i> User Profile(<span  th:text="${#authentication.principal.displayName}"></span>)</a>
                        </li>
                        <li><a href="#"><i class="fa fa-gear fa-fw"></i> Settings</a>
                        </li>
                        <li class="divider"></li>
                        <li><a href="login.html" th:href="@{/logout}"><i class="fa fa-sign-out fa-fw"></i> Logout</a>
                        </li>
                    </ul>
                    <!-- /.dropdown-user -->
                </li>
                <!-- /.dropdown -->
````

Play framework 1.2.7 で “Request exceeds 8192 bytes”が発生した

事象

Play framework 1.2.7 で実装した RestAPIに c# で実装した RestClient から GET /path/to/api?var1=hoge&var2=huga~以下省略 といった要領でQueryStringのパラメータを大量に設定すると logs/application.log に Request exceeds 8192 bytes と出力され処理が異常終了します。

原因

ログのメッセージを出力しているのは play.server.PlayHandler#exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e)です。

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception {
        try {
            // If we get a TooLongFrameException, we got a request exceeding 8k.
            // Log this, we can't call serve500()
            Throwable t = e.getCause();
            if (t instanceof TooLongFrameException) {
                Logger.error(t, "Request exceeds 8192 bytes");
            }
            e.getChannel().close();
        } catch (Exception ex) {
        }
    }

ならばと、例外 TooLongFrameException を送出している箇所を探すと、org.jboss.netty.handler.codec.http.HttpMessageDecoder.readLine(ChannelBuffer buffer, int maxLineLength) でした。例外を送出する時に 変数 sb を確認すると、GET /path/to/api?var1=hoge&var2=huga~以下省略が設定されていました。

    private String readLine(ChannelBuffer buffer, int maxLineLength) throws TooLongFrameException {
        StringBuilder sb = new StringBuilder(64);
        int lineLength = 0;
        while (true) {
            byte nextByte = buffer.readByte();
            if (nextByte == HttpCodecUtil.CR) {
                nextByte = buffer.readByte();
                if (nextByte == HttpCodecUtil.LF) {
                    return sb.toString();
                }
            } else if (nextByte == HttpCodecUtil.LF) {
                return sb.toString();
            } else {
                if (lineLength >= maxLineLength) {
                    // TODO: Respond with Bad Request and discard the traffic
                    //    or close the connection.
                    //       No need to notify the upstream handlers - just log.
                    //       If decoding a response, just throw an exception.
                    throw new TooLongFrameException(
                            "An HTTP line is larger than " + maxLineLength +
                            " bytes.");
                }
                lineLength ++;
                sb.append((char) nextByte);
            }
        }
    }

上限値の maxLineLength は HttpMessageDecoderのデフォルトコンストラクタで決まっていました。

対策

今回は時間の関係でQueryStringのパラメータ名を短くして対応しました。

が、maxLineLength の上限を引き上げるには play.server.play.server#getPipeline()メソッドで行っている new HttpRequestDecoder()に引数を指定すれば上限を引き上げられます。たぶん。

play-1.2.7.jar を作り直す必要があるので、QueryStringのパラメータ名を短くすることにしました。

spring boot その6 - hot deploy

実装したソースは https://github.com/huruyosiathatena/springboot/tree/bf14db6cee0c0eba88894932722a514053f49bbd にあります。

再起動せずにjavaソースコードの変更を反映

小さな修正を行う都度にjavaを再起動するのは手間なので pom.xmlを編集してhot deployを有効にします。

 
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>springloaded</artifactId>
        </dependency>

UltraMonkey-L7 の sslproxy に Logjam 対策を行う

Firefox ver39 で サイトにアクセスできなくなってしまった。

Firefox でアクセスすると

SSL received a weak ephemeral Diffie-Hellman key in Server Key Exchange handshake message. (エラーコード: ssl_error_weak_server_ephemeral_dh_key) というエラーが出るようになりました。

Firefox 39 サイト互換性情報 - Mozilla | MDN の 「1023 ビット未満の DHE キーは受け入れられなくなりました」にしています。

対応

DHE鍵の鍵長が512 だったので 2048 にします。

www.agilegroup.co.jp

を参考にして、sslproxy に適用します。

鍵長を 2048 bit にして作成

$ sudo openssl dhparam -out /etc/l7vs/sslproxy/dh2048.pem 2048

/etc/l7vs/sslproxy/sslproxy.target_1.cf を修正

/etc/l7vs/sslproxy/sslproxyadm.cf に sslproxyadm.cf:conf_file = "/etc/l7vs/sslproxy/sslproxy.target_1.cf"と定義されているので /etc/l7vs/sslproxy/sslproxy.target_1.cfを修正します。

ssl_options = "SSL_OP_NO_SSLv2"
ssl_options = "SSL_OP_NO_SSLv3"
tmp_dh_file = "dh2048.pem"
cipher_list = "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH"
  • 上の二つで SSL の v2とv3 の の利用を禁止します。
  • tmp_dh_file に 2048bit で作り直したDHE鍵を指定します。
  • cipher_list で exportを禁止します。(たぶん)

変更後に再起動して https://weakdh.org/sysadmin.html で自サイトの検査を行い、 Good News! This site uses strong (2048-bit or better) key exchange parameters and is safe from the Logjam attack. なりました。

spring boot その5 - SB Admin2 を組み込む

実装したソースは https://github.com/huruyosiathatena/springboot/tree/4589c56c81f9907a55356d44704a3ff08235fe39 にあります。

レイアウトを SB Admin2 に変更

レイアウトでもう少し楽するために SB Admin2 を組み込みます。2015年8月2日時点でSB Admin2のバージョンは 1.0.7でした。

startbootstrap.com

SB Admin2から必要なファイルをコピーする方針

startbootstrap-sb-admin-2-1.0.7.zip の内容

SB Admin2 をダウンロードしてzipの中を見ると、 SB Admin2自体と 利用しているコンポーネントが入っていました。

startbootstrap-sb-admin-2-1.0.7 
  +- bower_components
  | +- bootstrap
  | | +- dist
  | | +- fonts
  | | +- grunt
  | | +- js
  | | +- less
  | +- bootstrap-social
  | ~ 省略 ~
  + dist
  |  +- css
  |  +- js
  +- js
  +- less
  +- pages
  +- index.html

Spring Bootのフォルダ構成

public フォルダに sbadmin2 フォルダを作成して、各distフォルダのファイルをコピーします。

SB Admin2の pages/blank.html を元にして layout を作成

まずは、簡単な pages/blank.html を再現しつつ、ブランクページを作成します。

pages/blank.htmlをコピー

startbootstrap-sb-admin-2-1.0.7.zip の pages/blank.html を bootstrap プロジェクトの src/main/resources/templates フォルダに sbadmin2.html という名前でコピーします。

html タグに xmlns を追加

thymeleafを利用するために xmlns を追加します。

<html lang="en"
xmlns:th="http://www.thymeleaf.org" 
xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout">

<div>layout:fragment </div> でコンテンツが入る場所を指定する

<div id="page-wrapper">の子にコンテンツを入れます

        <!-- Page Content -->
        <div id="page-wrapper">
            <div layout:fragment="content"></div>
        </div>
        <!-- /#page-wrapper -->

コントローラー作成

新しく SbAdmin2Controller を作成し、blankアクションを作成します。

package com.hatenablog.huruyosi.springboot.controllers;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequestMapping("/sbadmin2/")
public class SbAdmin2Controller {
    
    @RequestMapping("/blank")
    public ModelAndView blank() {
        return new ModelAndView("SbAdmin2Controller/blank");
    }
}

テンプレート SbAdmin2Controller/blank.html を作成

新しく src/main/resources/templates/SbAdmin2Controller/blank.html を作成します。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="sbadmin2">
<body>
  <div layout:fragment="content">
            <div class="container-fluid">
                <div class="row">
                    <div class="col-lg-12">
                        <h1 class="page-header">Blank</h1>
                    </div>
                    <!-- /.col-lg-12 -->
                </div>
                <!-- /.row -->
            </div>
            <!-- /.container-fluid -->
   </div>
</body>
</html>

ブラウザからアクセス

アプリを起動して ブラウザから http://localhost:8080/sbadmin2/blank にアクセスします。 metaタグとinputタグに閉じタグがないのでテンプレートのコンパイルエラーが発生します。 テンプレートモードをLEGACYHTML5することで、閉じタグの修正をせずとも利用できるようすが、今回は閉じタグを追加しました。

qiita.com

閉じタグを直し終わるとがっかりするレイアウトで表示されます。css へのリンクを直していないからです。 f:id:huruyosi:20150802110307p:plain

css と js のリンクを直しつつ、startbootstrap-sb-admin-2-1.0.7から 依存するjsとcssをコピー

崩れているレイアウトを直していきます。

SB Admin2の css と js

startbootstrap-sb-admin-2-1.0.7/dist の css と js フォルダを プロジェクトの public/sbadmin2 にコピーします。

そして、src/main/resources/templates/sbadmin2.html の cssとjsの読み込みを修正します。

    <!-- Custom CSS -->
    <link th:href="@{/sbadmin2/css/sb-admin-2.css}" rel="stylesheet" />

src/main/resources/templates/sbadmin2.htmlの最後の方にあります。

    <!-- Custom Theme JavaScript -->
    <script th:src="@{/sbadmin2/js/sb-admin-2.js}"></script>

依存するコンポーネント

bower_components フォルダ配下のコンポーネントを直していきます。

startbootstrap-sb-admin-2-1.0.7/bower_components/jquery/dist をプロジェクトの public/sbadmin2/bower_components/jquery へコピーし、 startbootstrap-sb-admin-2-1.0.7/startbootstrap-sb-admin-2-1.0.7/bower_components/bootstrap/dist を プロジェクトの public/sbadmin2/bower_components/bootstrap へコピーします。

そして、src/main/resources/templates/sbadmin2.html のcss と js の読み込みを修正します。

    <!-- Bootstrap Core CSS -->
    <link th:href="@{/sbadmin2/bower_components/bootstrap/css/bootstrap.min.css}" rel="stylesheet" />

~途中省略 ~

    <!-- jQuery -->
    <script th:src="@{/sbadmin2/bower_components/jquery/jquery.min.js}"></script>

    <!-- Bootstrap Core JavaScript -->
    <script th:src="@{/sbadmin2/bower_components/bootstrap/js/bootstrap.min.js}"></script>

再度ブラウザからアクセスします。それっぽい見た目になってきました。 f:id:huruyosi:20150802112513p:plain

同様の方法で metisMenu と font-awesome を修正します。font-awesome はdist フォルダがないので、cssとfonts フォルダをコピーしました。 これで見られる状態になりました。 f:id:huruyosi:20150802113427p:plain

プロジェクトの public の状態

f:id:huruyosi:20150802113735p:plain

blank 以外のページ

作業の流れ

startbootstrap-sb-admin-2-1.0.7/pages のhtmlファイルを組み込んでいきます。

  1. src/main/resources/templates/SbAdmin2Controller にファイルをコピー
  2. <html>の属性をblankと同じにします。
  3. link タグで読み込んでいるcss のうち、レイアウトの /src/main/resources/templates/sbadmin2.html で読み込んでいるものを削除し、残ったものはページ固有のcssファイルなので href属性をth:href属性に書き換えます、
  4. bodyタグの<div id="page-wrapper"><div layout:fragment="content">に置き換え、親の<div id="wrapper">の開始タグと終了タグを削除します。
  5. bodyタグのnavタグを削除します。
  6. bodyタグの最後にある <script>タグのうち、レイアウトの /src/main/resources/templates/sbadmin2.html で読み込んでいるものを削除します。残りはページ固有のものなので、レイアウトに反映できるようにします。
  7. コントローラーにアクションを追加します。

ページ固有のJavaScriptをレイアウトに反映する。

レイアウトの /src/main/resources/templates/sbadmin2.html に JavaScriptを記載するための fragmentを作成します。

    <!-- Metis Menu Plugin JavaScript -->
    <script th:src="@{/sbadmin2/bower_components/metisMenu/metisMenu.min.js}"></script>

    <div layout:fragment="moreScripts" th:remove="tag"></div>
    
    <!-- Custom Theme JavaScript -->
    <script th:src="@{/sbadmin2/js/sb-admin-2.js}"></script>

JavaScript用のfragmentができたので、コンテンツのテンプレートに記載します。たとえばtales.html ではこの様にしました。

<div layout:fragment="moreScripts">
    <!-- DataTables JavaScript -->
    <script th:src="@{/sbadmin2/bower_components/datatables/media/js/jquery.dataTables.min.js}"></script>
    <script th:src="@{/sbadmin2/bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js}"></script>

    <!-- Page-Level Demo Scripts - Tables - Use for reference -->
    <script>
    $(document).ready(function() {
        $('#dataTables-example').DataTable({
                responsive: true
        });
    });
    </script>
</div>

huruyosi.hatenablog.com

login.html 用のレイアウト

login.html ではナビゲーションを表示しないレイアウトを作成しました。

spring boot その4 - Thymeleaf の layout を利用して、 ページ固有の title、meta、 style、script タグを出力する

実装したソースは https://github.com/huruyosiathatena/springboot/tree/3cf89896ff8e272b437f988f1c39f2d4a988be0e にあります。

layoutを使って 主コンテンツ以外を出力する

一つ前の記事で Thymeleaf のlayoutを使いました。

huruyosi.hatenablog.com

webページを作成していると、html <div layout:fragment="content"> よりも外側の共通部分にタグを出力する必要が出てきます。 今回は下の4つのタグをページ単位に設定します。

  1. title タグ
  2. meta タグ
  3. style タグ
  4. script タグ

title タグ

方法1) titleタグのテキストをそのまま反映する

テンプレートの head タグ内に titleタグを記載すれば、 レイアウトのtitleタグが置き換わります。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout">
<head>
  <title>a page </title>
</head>
<body>
  <div layout:fragment="content">
        <h1>using template ending Thymeleaf </h1>
        <p class="lead"><span th:text="${message}">param1 content</span></p>
  </div>
</body>
</html>

方法2) layout:title-pattern を使う

github.com

titleタグに layout:title-pattern 属性を設定して タイトルの形式を指定します。

<title layout:title-pattern="$CONTENT_TITLE - $DECORATOR_TITLE">Starter Template for Bootstrap</title> とすると、$CONTENT_TITLE には テンプレートの titleタグのテキストが代入され、 $DECORATOR_TITLEには レイアウトの titleタグのテキストが代入されます。

たとえば thymeleaf.html に

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout">
<head>
  <title>a page</title>
</head>
<body>
  <div layout:fragment="content">
        <h1>using template ending Thymeleaf </h1>
        <p class="lead"><span th:text="${message}">param1 content</span></p>
  </div>
</body>
</html>

と記載すると、出力される html のtitle タグのテキストが a page - Starter Template for Bootstrapになります

metaタグ

コンテンツページに head タグ内に書きます。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout">
<head>
  <title>a page </title>
  <meta name="description" content="ページ固有の情報を thymeleaf の layout に適用する方法 " />
</head>
<body>
  <div layout:fragment="content">
        <h1>using template ending Thymeleaf </h1>
        <p class="lead"><span th:text="${message}">param1 content</span></p>
  </div>
</body>
</html>

と書くと f:id:huruyosi:20150802010759p:plain になります。レイアウトのmetaタグの後ろに挿入されました。

style タグ

metaタグと同様です。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout">
<head>
  <title>a page </title>
  <meta name="description" content="ページ固有の情報を thymeleaf の layout に適用する方法 " />

<style type="text/css">
  h1
  {
    text-align: left;  
  }
</style>
</head>
<body>
  <div layout:fragment="content">
        <h1>using template ending Thymeleaf </h1>
        <p class="lead"><span th:text="${message}">param1 content</span></p>
  </div>
</body>
</html>

と書くと f:id:huruyosi:20150802011217p:plain になります。linkタグと script の間に挿入されました。

scriptタグ

metaやstyleと同様に head タグ内に書くのもアリですが、 body 内の最後に配置します。

レイアウトに <div layout:fragment> を使ってscriptタグを配置する場所を指定します。th:remove="tag"を指定することで、子要素は残し、<div layout:fragment="moreScripts" th:remove="tag">のタグだけを削除します。

tm8r.hateblo.jp

レイアウト

    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <script src="/bootstrap/js/bootstrap.min.js"></script>
    <!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
    <script src="/assets/js/ie10-viewport-bug-workaround.js"></script>
    <div layout:fragment="moreScripts" th:remove="tag">
    </div>
  </body>

コンテンツ

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout">
<head>
  <title>a page </title>
  <meta name="description" content="ページ固有の情報を thymeleaf の layout に適用する方法 " />

<style type="text/css">
  h1
  {
    text-align: left;  
  }
</style>
</head>
<body>
  <div layout:fragment="content">
        <h1>using template ending Thymeleaf </h1>
        <p class="lead"><span th:text="${message}">param1 content</span></p>
  </div>
  <div layout:fragment="moreScripts">
    <script type="text/javascript">
      alert("exec moreScripts !!");
    </script>
  </div>
</body>
</html>

とすると f:id:huruyosi:20150802012028p:plain になります。依存関係のために順序指定場合、たとえば、jQueryよりも前に実行したい場合には レイアウトが

    <div layout:fragment="beforejQuery" th:remove="tag">
    </div>
    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <script src="/bootstrap/js/bootstrap.min.js"></script>
    <!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
    <script src="/assets/js/ie10-viewport-bug-workaround.js"></script>
    <div layout:fragment="moreScripts" th:remove="tag">
    </div>
  </body>

となる。はず。(未検証)

spring boot その3 - テンプレートエンジンの Thymeleaf を組み込む

実装したソースは https://github.com/huruyosiathatena/springboot/tree/db743062dad8411906e959d00e3326c4249ca50f にあります。

view に Thymeleaf を使う

Tthymeleaf を利用して、 前回( spring boot その2 - bootstrapを組み込んで静的なページを表示する - huruyosi’s blog )の静的ファイルと同等の表示を行います。

pom.xml に依存関係を追加

dependencies に追加します。

     <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

コントローラーにアクションを追加

HelloControllerに thymeleaf メソッドを追加します。 メソッドの戻り値が org.springframework.web.servlet.ModelAndView になり テンプレートの情報を渡します。

   @RequestMapping("/thymeleaf")
    public ModelAndView thymeleaf() {
        Map<String, String> model = new HashMap<String, String>();
        model.put("message", "hello world");
        return new ModelAndView("HelloController/thymeleaf", model);
    }
  • ModelAndView のコンストラクタの第1引数にテンプレートファイルを指定します。
  • ModelAndView のコンストラクタの第2引数にテンプレートのパラメータを指定します。

テンプレートファイルを作成

テンプレートファイルは src/main/resources/templates の下におきます。テンプレートファイルは コントローラーのクラス名のディレクトリを作成し、その下に置きます。今回は src/main/resources/templates/HelloController/thymeleaf.html になります。

テンプレートの<span th:text="${message}">param1 content</span> にアクションの model.put("message", "hello world"); で設定したhello worldが表示されます。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" 
xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout">
  <head>
    <meta charset="utf-8" /> 
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
    <meta name="description" content="" />
    <meta name="author" content="" />

    <title>Starter Template for Bootstrap</title>

    <!-- Bootstrap core CSS -->
    <link href="/bootstrap/css/bootstrap.min.css" rel="stylesheet" />

    <!-- Custom styles for this template -->
    <link href="starter-template.css" rel="stylesheet" />

    <!-- Just for debugging purposes. Don't actually copy these 2 lines! -->
    <!--[if lt IE 9]><script src="/assets/js/ie8-responsive-file-warning.js"></script><![endif]-->
    <script src="/assets/js/ie-emulation-modes-warning.js"></script>

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>

  <body>

    <nav class="navbar navbar-inverse navbar-fixed-top">
      <div class="container">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="#">Project name</a>
        </div>
        <div id="navbar" class="collapse navbar-collapse">
          <ul class="nav navbar-nav">
            <li class="active"><a href="#">Home</a></li>
            <li><a href="#about">About</a></li>
            <li><a href="#contact">Contact</a></li>
          </ul>
        </div><!--/.nav-collapse -->
      </div>
    </nav>

    <div class="container">

      <div class="starter-template">
        <h1>using template ending Thymeleaf </h1>
        <p class="lead"><span th:text="${message}">param1 content</span></p>
      </div>

    </div><!-- /.container -->


    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <script src="/bootstrap/js/bootstrap.min.js"></script>
    <!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
    <script src="/assets/js/ie10-viewport-bug-workaround.js"></script>
  </body>
</html>

ホットデブロイとか thymeleaf のキャッシュ

ホットデブロイ

model.put("message", "hello world");hello worldを変更した後に画面への表示に反映させるためには再起動を行う必要があります。それでは時間がかかるので eclipseデバッグから起動すると、ホットデブロイの状態になります。

thymeleaf のキャッシュ

thymeleaf の変更も再起動が必要なので、対策を行います。

thymeleaf のキャッシュを無効にするために application.yml に spring.thymeleaf.cache: falseと定義します。開発中にだけキャッシュを無効にするために 開発用のファイルを作成します。

application.yml

spring:
  profiles.active: dev

application-dev.yml

spring:
    thymeleaf.cache: false

qiita.com

ファイルを追加した状態のプロジェクト

f:id:huruyosi:20150726093424p:plain

src/main/resoruce にテンプレートファイルとapplication.ymlを追加しました。