huruyosi’s blog

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

AbstractViewを継承してJasperReportでCSVファイルを作成する

CSVファイルをダウンロードする実装として、CSV形式でレンダリングするviewを実装します。

実装

コントローラー

パラメータに応じてデータを検索します。検索結果はbeanのListに設定します。 org.springframework.web.servlet.view.AbstractViewの具象クラスにパラメータとして下のものを渡します。

  • JasperReportの.japserファイル
  • JasperReportのデータソースになる検索結果
  • JasperReportのパラメータになるMap
@Controller
@RequestMapping("report")
public class ReportController {

    @Autowired
    TimeEntriesFacade timeEntrysEntriesFacade;

    /**
    * 作業時間をCSVにして出力する
    *
    * @return
    * @throws NotFoundException
    */
    @RequestMapping(value = "/timeEntries/{from_date}/{to_date}", method = RequestMethod.GET)
    public ModelAndView timeEntries(
              @PathVariable("from_date") @DateTimeFormat(pattern="yyyy-MM-dd")Date from_date
            , @PathVariable("to_date") @DateTimeFormat(pattern="yyyy-MM-dd")Date to_date) {

        List<ReportingTimeEntries>csvRows = timeEntrysEntriesFacade.loadEntries(from_date, to_date);

        final File jasper = JavaUtil.findResourceFile("jasper_report/timeEntries.jasper");
        Map<String,Object> reportParams = new HashMap<String,Object>();

        ModelAndView mav = new ModelAndView(new JasperReprotCSVView<ReportingTimeEntries>(jasper, reportParams, csvRows, "time_entries.csv"));
        return mav;
    }

}

AbstractViewの具象クラス

レスポンスに必要なヘッダーを設定し、response.getOutputStream()で得られるoutputStreamにJaspreReportが書き込みます。JaspreReportを利用する実装はCSVクラスに異状します。

import java.io.File;
import java.io.OutputStream;
import java.util.List;
import java.util.Map;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import net.sf.jasperreports.engine.JRDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.data.JRBeanCollectionDataSource;

import org.springframework.web.servlet.view.AbstractView;

/**
 *
 * Jasper Reportを利用してCSVを出力するView
 *
 */
public class JasperReprotCSVView<T> extends AbstractView {

    /** The content type for an Excel response */
    private static final String CONTENT_TYPE = "text/csv;charset=SHIFT-JIS";

    /**
    * jasperファイル
    */
    private File jasperFile ;

    /**
    * CSVの元データ
    */
    private List<T> csvRows ;

    /** JasperReportに渡すパラメター */
    private Map<String,Object> reportParams;

    /**
    * <code>Content-Disposition: attachment; filename=xxx</coe>に設定するファイル名
    */
    private String filename ;

    /**
    * デフォルトコンストラクター.
    * Sets the content type of the view to "text/csv;charset=SHIFT-JIS".
    */
    public JasperReprotCSVView() {
        setContentType(CONTENT_TYPE);
    }

    public JasperReprotCSVView(File jasperFile, Map<String,Object> reportParams, List<T> csvRows, String filename){
        this();
        this.jasperFile = jasperFile;
        this.csvRows =csvRows;
        this.reportParams = reportParams;
        this.filename = filename;
    }

    /* (非 Javadoc)
    * @see org.springframework.web.servlet.view.AbstractView#generatesDownloadContent()
    */
    @Override
    protected boolean generatesDownloadContent() {
        return true;
    }

    /* (非 Javadoc)
    * @see org.springframework.web.servlet.view.AbstractView#renderMergedOutputModel(java.util.Map<java.lang.String,java.lang.Object>, javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)
    */
    @Override
    protected void renderMergedOutputModel(Map<String, Object> model,
            HttpServletRequest request, HttpServletResponse response)
            throws Exception {

        if ( jasperFile == null || ! jasperFile.exists()){
            // TODO throw exception!!
        }
        // Set the content type.
        response.setContentType(getContentType());
        response.setHeader("Content-Disposition","attachment; filename=" + filename);

        ServletOutputStream out = response.getOutputStream();
        write(jasperFile, reportParams, csvRows, out);
        out.flush();
    }

    /**
    * CSVを作成ストリームに書き込みます。
    * @param jasperFile jasper reportの.jasperファイル
    * @param reportParams レポートのパラメータ
    * @param dataSource データソース
    * @param out CSVを書き込むストリーム
    * @throws JRException
    * @throws JasperFileNotFoundException
    */
    void write(File jasperFile, Map<String,Object> reportParams, List<T> dataSource,OutputStream out)
            throws JRException, JasperFileNotFoundException
    {
        write(jasperFile, reportParams, new JRBeanCollectionDataSource(dataSource), out);
    }

    /**
    * CSVを作成ストリームに書き込みます。
    * @param jasperFile jasper reportのJRXMLファイル
    * @param reportParams レポートのパラメータ
    * @param dataSource データソース
    * @param out CSVを書き込むストリーム
    * @throws JRException
    * @throws JasperFileNotFoundException
    */
    void write(File jasperFile, Map<String,Object> reportParams, JRDataSource dataSource,OutputStream out)
            throws JRException, JasperFileNotFoundException
    {
        new Csv().write(jasperFile, reportParams, dataSource, out);
    }

}

CSVクラスの実装

ダウンロードしたCSVファイルはExcelで利用することを期待しているので、文字コードをShift-JISにし、改行コードをCRLFにします。

import java.io.File;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import net.sf.jasperreports.engine.JRDataSource;
import net.sf.jasperreports.engine.JREmptyDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JRParameter;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.data.JRBeanCollectionDataSource;
import net.sf.jasperreports.engine.export.JRCsvExporter;
import net.sf.jasperreports.export.SimpleExporterInput;
import net.sf.jasperreports.export.SimpleWriterExporterOutput;

/**
 * CSV形式のデータを作成します。
 *
 */
public class Csv {

    /**
    * CSVファイルのエンコード
    */
    final String fileEncode = "MS932";

    /**
    * CSVファイルを作成します。
    * @param jasperFile jasper reportの.jasperファイル
    * @param reportParams レポートのパラメータ
    * @param dataSource データソース
    * @param out CSVを書き込むストリーム
    * @throws JRException
    * @throws JasperFileNotFoundException 引数 jasperFileに指定されたファイルが存在しない
    */
    public <T> void write(File jasperFile, Map<String,Object> reportParams, List<T> dataSource,OutputStream out)
            throws JRException, JasperFileNotFoundException
    {
        write(jasperFile, reportParams, new JRBeanCollectionDataSource(dataSource), out);
    }

    /**
    * CSVファイルを作成します。
    * @param jasperFile jasper reportの.jasperファイル
    * @param reportParams レポートのパラメータ
    * @param dataSource データソース
    * @param out CSVを書き込むストリーム
    * @throws JRException jasper reportの処理で例外が発生した
    * @throws JasperFileNotFoundException 引数 jasperFileに指定されたファイルが存在しない
    */
    public void write(File jasperFile, Map<String,Object> reportParams, JRDataSource dataSource,OutputStream out)
            throws JRException, JasperFileNotFoundException
    {
        if ( jasperFile == null || ! jasperFile.exists()){
            throw new JasperFileNotFoundException(jasperFile);
        }

        Map<String, Object> fillParams = new HashMap<String,Object>(reportParams);

        fillParams.put("SUBREPORT_DIR", jasperFile.getParentFile().getAbsolutePath()+File.separator);
        fillParams.put(JRParameter.IS_IGNORE_PAGINATION, Boolean.TRUE);

        JasperPrint jrprint = JasperFillManager.fillReport(jasperFile.getAbsolutePath(),
                fillParams, dataSource != null ? dataSource : new JREmptyDataSource());

        // 改行コードをCRLFにする
        jrprint.setProperty("net.sf.jasperreports.export.csv.record.delimiter", "\r\n");

        JRCsvExporter exporter = new JRCsvExporter();
        exporter.setExporterInput(new SimpleExporterInput(jrprint));
        exporter.setExporterOutput(new SimpleWriterExporterOutput(out, fileEncode));
        exporter.exportReport();
    }
}

TODO

Redmine 3.1.1.stable と eclipse 4.3を連携させる

環境

インストール

Redmine

Redmine3.xに対応したと思われるものを使います。

$ cd $REDMINE_ROOT/plugins
$ sudo git clone git://github.com/danmunn/redmine_mylyn_connector.git
$ cd ..
$ bundle exec rake redmine:plugins:migrate RAILS_ENV=production

eclipse 旧バージョン プラグイン

Redmine-Mylyn Connector download | SourceForge.net にあるプラグインをインストールを使います。

ソフトウェアサイト 「http://redmin-mylyncon.sourceforge.net/update-site/N/」を指定して インストールします。

インストール後に「タスク・リポジトリー」ビューから「タスク・リポジトリーの追加」を選択し、Redmineが表示されるのを確認します。

f:id:huruyosi:20151129174413p:plain

eclipse 新バージョン プラグイン

メンテナンスされている プラグインhttps://github.com/ljader/redmine-mylyn-plugin から インストールします。最近zipファイルのホスティング先を変更したようで、ソースからビルドします。

$ git clone git://github.com/ljader/redmine-mylyn-plugin.git
$ cd redmine-mylyn-plugin
$ mvn package

f:id:huruyosi:20151129181442p:plain 成功すると、\net.sf.redmine_mylyn.p2repository\target ディレクトリの下にnet.sf.redmine_mylyn.p2repository-0.4.0-SNAPSHOT.zipが作成されます。インストールはソフトウェア・サイトの追加ダイアログの f:id:huruyosi:20151129175437p:plain 「アーカイブ」ボタンを押して、zipファイルを選択し、プラグインをインストールします。

mylynにredmineを登録

f:id:huruyosi:20151129174413p:plain 「タスク・リポジトリー」ビューから「タスク・リポジトリーの追加」を選択し、 コネクタの一覧からRedmineを選択します。

f:id:huruyosi:20151129180920p:plain Redmineリポジトリー設定にRedmineの情報を入力します。

  • サーバ:RedmineのURL
  • ラベル:mylynでタスク・リポジトリーを識別するための名称
  • ユーザid:RedmineにログインできるユーザID
  • パスワード:上のRedmineのユーザIDのパスワード
  • API-Key:Redmineの個人設定画面からAPIキーを発行し、コピペ

f:id:huruyosi:20151129180954p:plain Redmineに登録されているチケットのクエリーから利用するものを選択します。

設定が終わると、「タスク・リスト」ビューにクエリーに合致するチケットが表示されます。

所感

チケットを作成するときの担当者をプルダウンで選択できるといいな。

参考にしたサイト

qiita.com

mysql で 最大値を残して delete する

きっかけ

Oracleでは delete from table_a where id < ( select max(id) from table_a ) とやるのだけど、MySQLでは

ERROR 1093 (HY000): You can't specify target table 'table_a' for update in FROM clause

となってしまう。ふと、OracleSQL*Plusでは バインド変数を使って手が込んだ削除を行ったのを思い出し、MySQLのユーザ定義変数で同等のことを行うことが思いついた。

連番を振るのに使われるテクニックを応用してみる。

macotasu.hatenablog.jp

ユーザ定義変数への代入を試す

mysq> select ( @max_v := max(id)) from table_a;
+----------------------+
| ( @max_v := max(id)) |
+----------------------+
|            334964562 |
+----------------------+
1 row in set (0.05 sec)

となった。ユーザ定義変数max_vに select の結果が入っているはず。

mysql> select @max_v ;
+-----------+
| @max_v    |
+-----------+
| 334964562 |
+-----------+
1 row in set (0.00 sec)

delete 文にしてみる

mysql> delete from table_a where id < @max_v;

MySQLのダンプファイルを圧縮しながら書き出す

100Gを超えるログテーブルをmysqldump でダンプする時に、名前つきパイプを使うことで圧縮しながらファイルに保存することができます。こうする事で、圧縮前のダンプファイルを保存するためのディスク領域を確保する必要がないので、ディスクの空き領域が少ない時でも大きなテーブルのダンプファイルを作成できます。

名前付きパイプ - Wikipedia

ダンプする時

$ mkfifo dmp.pipe
$ cat dmp.pipe | gzip -c  > xxtable.dmp.gzip &
$ mysqldump -u hoge -p dbname -r dmp.pipe

mkfifoで名前つきパイプをあらかじめ作成しておき、catの出力をgzipの圧縮をバックグランドプロセスで行います。catは名前つきパイプdmp.pipeに書き込まれるのを待っています。

その状態でmysqldumpを行い -rオプションで書き込み先のファイルに名前つきパイプを指定します。mysqldumpは名前つきパイプに書き込みを行い、バックグラウンドで実行しているcatがmysqldumpが書き込んだ結果を読み込み、無名パイプを使ってgzipに送ります。gzipcatが送ったmysqldumpの書き込み結果を圧縮し、リダイレクト先のファイルに書き込みます。

mysqldumpが終了するときに名前つきパイプへの書き込みがクローズされ、catコマンドが終了し、それを受けてgzipが終了します。

インポートする時

$ gzcat xxtable.dmp.gzip >> dmp.pipe &
$ mysql -u hoge -p dbname < dmp.pipe 

ダンプ作成とは逆のことを行います。 gzcatが名前つきパイプに展開結果を書き込む処理をバックグラウンドで行います。

mysqlの入力のリダイレクトに名前つきパイプを指定することで、gzcatが展開した結果をmysqlに送ります。

Jasper Reportのクロス集計で横軸が1ページを超えた時には改ページさせる

クロス集計(crosstab)を使った集計を行った結果が横軸が1ページの幅を超えた時に、縦方向に空きがあるとクロス集計が2行になって表示れます。 stackoverflow.com

この場合には、 crosstabのプロパティ「Column Break Offset」を印刷範囲の横幅と同じにすることで、改ページされます。 iReport Designer 5.6のプロパティウィンドウから設定します。

f:id:huruyosi:20150910131330p:plain

jrxml ではこのような定義です。

<crosstab columnBreakOffset="810" ignoreWidth="false">
                <reportElement x="0" y="0" width="801" height="450" uuid="c8bc733f-47b1-4185-b619-2d85e7440535"/>
                <crosstabHeaderCell>
                    <cellContents>
~ 省略 ~
</crosstab >

spring boot その9 - spring security で Remember-Me認証を行う

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

Remember Me 認証

ログイン画面にある にある「Remember Me」にチェックをつけてログインすることで、次回アクセス時に認証を省略できます。 f:id:huruyosi:20150816123226p:plain

動き

「Remember Me」にチェックをつけてログインを行うと、「remember-me」という名前でcookieが発行されます。cookieにはtokenが設定されており、spring-security内部でログインIDと対応付けられています。

ブラウザを閉じた後に、認証が必要がURLにアクセスした時にremember-meのcookieが送られてきたら spring-securityが対応付けられたログインIDを探して、登録されていれば認証が省略されます。

Remember Meのトークンの管理

デフォルトではMapを用いているorg.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl クラスが利用されます。この方法では spring bootを再起動すると消えてしまうので、 org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImplクラスを利用して DB に保存します。

DBに保存するためのテーブルは JdbcTokenRepositoryImpl#setCreateTableOnStartup(boolean createTableOnStartup)にtrueを指定することで自動的に作られます。しかし、テーブルが存在する場合には失敗するので、あらかじめ作成します。

DDLはリファレンス http://docs.spring.io/spring-security/site/docs/3.2.x/reference/htmlsingle/#remember-me-persistent-token に記載されています。

-- remember me 認証
create table persistent_logins(
  username varchar(64) not null,
  series varchar(64) primary key, 
  token varchar(64) not null,
  last_used timestamp not null
);

認可 - @Secured

@Securedに指定するロールにIS_AUTHENTICATED_REMEMBEREDを指定することでRemember-Meの認証情報でアクセスが許可されます。

意味
IS_AUTHENTICATED_ANONYMOUSLY ログインしていなくても許可する
IS_AUTHENTICATED_REMEMBERED Remember-Me認証でアクセスを許可する
IS_AUTHENTICATED_FULLY 現在のセッションでログインしていれば許可する。Remember-Me認証でアクセスした場合には否認される。

実際にはロール名と併せて@Secured{"IS_AUTHENTICATED_REMEMBERED","ROLE_STAFF"})という指定になると思います。

認可 - WebSecurityConfigurerAdapter#configure(HttpSecurity http)

HttpSecurityで設定する認証方法の指定も 上と同様に種類を使い分けます。

現在のセッションで認証が必要な場合には.fullyAuthenticated()を使います。

       http
                .authorizeRequests()
                .antMatchers("/sbadmin2/css/**").permitAll()
                .antMatchers("/sbadmin2/js/**").permitAll()
                .antMatchers("/sbadmin2/bower_components/**").permitAll()
                .anyRequest().fullyAuthenticated()

Remember-Me認証も許可する場合には.authenticated()を使います。

       http
                .authorizeRequests()
                .antMatchers("/sbadmin2/css/**").permitAll()
                .antMatchers("/sbadmin2/js/**").permitAll()
                .antMatchers("/sbadmin2/bower_components/**").permitAll()
                .anyRequest().authenticated()

テンプレートの記述

Remember-Meのチェックボックスは spring-securityの実装(org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices#rememberMeRequested(HttpServletRequest request, String parameter))に従う必要があります。

<input name="remember-me" type="checkbox" value="yes" />Remember Me
  • name属性はremember-meでなければなりません。
  • value属性は下のいずれかでなければなりません。trueon1yes

有効期間

デフォルトでは前回のアクセスから 2 週間は 有効です。変更する場合は WebSecurityConfigurerAdapter#configure(HttpSecurity http) 内でhttp.rememberMe().tokenValiditySeconds(604800)という要領で指定します。tokenValiditySecondsに秒数を指定します。

テンプレートの実装

<!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-me" type="checkbox" value="yes" />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>

WebSecurityConfigurerAdapter の実装

package com.hatenablog.huruyosi.springboot.config;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
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.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
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().authenticated()
        .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()
        .and().rememberMe()
                .tokenRepository(jdbcTokenRepository())
                .tokenValiditySeconds(604800) // remember for a week. ( 1 * 60 * 60 * 24 * 7 ) sec
        ;
    }

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.userDetailsService(userDetailsService);
    }
    
     /**
     * Remember Me 認証に利用するトークンのリポジトリ
    * @return
    */
    @Bean
    public PersistentTokenRepository jdbcTokenRepository() {
        JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
        repository.setDataSource(dataSource);
//     repository.setCreateTableOnStartup(true);
        return repository;
    }
}

コントローラーの実装

package com.hatenablog.huruyosi.springboot.controllers;

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

@Controller
@RequestMapping("/sbadmin2/")
@Secured("IS_AUTHENTICATED_REMEMBERED")
public class SbAdmin2Controller {

    @RequestMapping("/index.html")
    public ModelAndView index() {
        return new ModelAndView("SbAdmin2Controller/index");
    }
~ 省略 ~
}

spring boot その8 - spring security で 認可を行う

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

前回( http://huruyosi.hatenablog.com/entry/2015/08/08/003303 ) は認証を行ったので、今回は認可です。

認可の方法

コントローラーに@Securedアノテーションを指定して、必要な権限名を指定します。 コントローラーのクラスとメソッドのどちらでも指定可能です。メソッドに指定する場合にはWebSecurityConfigurerAdapterクラスの具象クラスに@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled=true)の指定が必要です。前回のソースに含まれています。

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

    @Autowired
    private DataSource dataSource;

~省略~

コントローラーの記載

下の記載では、このように振舞います・

  • /sbadmin2/index.html はログインしていればアクセス可能
  • /sbadmin2/forms.html はログインして ROLE_STAFFの権限を割り当てられていればアクセス可能
  • /sbadmin2/tables.html はログインして ROLE_ADMINの権限を割り当てられていればアクセス可能
@Controller
@RequestMapping("/sbadmin2/")
@Secured("IS_AUTHENTICATED_FULLY")
public class SbAdmin2Controller {

    @RequestMapping("/index.html")
    public ModelAndView index() {
        return new ModelAndView("SbAdmin2Controller/index");
    }

    @Secured("ROLE_STAFF")
    @RequestMapping("/forms.html")
    public ModelAndView forms() {
        return new ModelAndView("SbAdmin2Controller/forms");
    }

    @Secured({"ROLE_ADMIN"})
    @RequestMapping("/tables.html")
    public ModelAndView tables() {
        return new ModelAndView("SbAdmin2Controller/tables" );
    }

テンプレートの表示制御

上のコントローラーの例では /sbadmin2/forms.html と /sbadmin2/tables.html は権限が割り当てられている場合にだけアクセスできます。画面の左側のメニューも権限が割り当て割れている場合にだけ表示するようにします。

                        <li sec:authorize="hasRole('ROLE_ADMIN')">
                            <a href="tables.html"><i class="fa fa-table fa-fw"></i> Tables</a>
                        </li>
                        <li sec:authorize="hasRole('ROLE_STAFF')">
                            <a href="forms.html"><i class="fa fa-edit fa-fw"></i> Forms</a>
                        </li>

sec:authorizeで必要な権限名を指定します。sec:authorizeを使うために xml名前空間を追加します。

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

spring securityが割り当てられている権限名を取得する方法

ログインしたユーザ情報はorg.springframework.security.core.userdetails.UserDetailsから得ます。 getAuthorities()メソッドの戻り値に許可されている権限の一覧を入れておきます。 今回はユーザ情報を提供するcom.hatenablog.huruyosi.springboot.service.MyUserDetail#create(Users entity)で DBの authorities テーブルに登録されている 権限名を得られる様にしています。

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.getUserId(), entity.getLoginId(), entity.getPassword(), entity.getDisplayName(), authorities);
    }

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

実際の画面

画面左側のメニューが変化しているのがわかると思います。

管理者の場合 f:id:huruyosi:20150810015002p:plain

スタッフの場合 f:id:huruyosi:20150810015017p:plain

ゲストの場合 f:id:huruyosi:20150810015021p:plain

課題

spring securityでは権限を階層化して管理することができます。spring security hierarchyで検索するといくつかサイトがでてきます。

クラスレベルではうまくいくのですが、メソッドレベルでは階層化した権限が効きませんでした。デバッグしてみると 権限チェックが2回行われていることがわかりました。

同じ状態で解決されたブログがありした。

Filterレベル(主として認証)の実装は指定した物を参照しているが、 Methodレベル(主として認可)の実装がデフォルト値が利用されているということでしょうか。

SpringSecurityのMethodSecurityとFilterSecurityではまるprepro.wordpress.com

GlobalMethodSecurityConfig で設定していることまでわかったのですが、カスタマイズがうまくいきません。xml を極力書かずに実装したかったので、今回は挫折しました。