Springで自作のエラーページを表示する

ここではSpring組み込みのWEBサーバーを使用するのではなく別のサーバーにデプロイするケースを考える。つまりEmbeddedServletContainerCustomizerが使えないケース。

環境

Spring Boot 1.4 + Thymeleaf

やり方

以下は404 Not FoundでnotFound.html、401 Forbiddenでforbidden.html、それ以外のエラーでerror.htmlを表示するサンプル。

import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.AbstractErrorController;
import org.springframework.boot.autoconfigure.web.ErrorAttributes;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class CustomErrorController extends AbstractErrorController {
    private static final String ERROR_PATH=  "/error";

    @Autowired
    public CustomErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes);
    }

    @RequestMapping(ERROR_PATH)
    public String handleErrors(HttpServletRequest request, Model model) {
        HttpStatus status = getStatus(request);

        if (status.equals(HttpStatus.NOT_FOUND)) {
            return "notFound";
        }
        if (status.equals(HttpStatus.FORBIDDEN)) {
            return "forbidden";
        }
        
        model.addAttribute("exception", getErrorAttributes(request, true));
        return "error";
    }

    @Override
    public String getErrorPath() {
        return ERROR_PATH;
    }
}

補足

モデルにgetErrorAttributesで取得したエラー情報を渡しているのはerror.htmlにエラーの内容を表示するため。

フォームの二重投稿を防止する

submitと同時にボタンをdisabledにする。

やり方

ページロードイベント中に以下を追加。

$('form').on('submit', function() {
    // 全てのボタンをdisabledにする
    $('button, input[type="submit"]').prop("disabled", true);
}).on('click', '[type=submit]', function() {
    var form = $(this).closest('form');

    // 押されたボタンと同名のhidden inputを作成する
    if (this.name) {
        form.children('input[type="hidden"].madeByScript').remove();
        form.append($('<input/>', {
            type: 'hidden',
            name: this.name,
            value: this.value,
            class: 'madeByScript'
        }));
    }
});

説明

単純にボタンをdisabledにするだけだとボタンに値が設定されていた場合にその値が飛ばなくなる。そこでsubmit前にボタンと同じnameとvalueを持つinputを作成して値を飛ばす。confirmでキャンセルされると作成したinputが残ってしまうため削除処理も入れておく。

ファイルダウンロードの終了をブラウザ側で検知したい

やり方

Cookieを使う。

$('form').on('submit', function() {
    const currentToken = getCookie("DOWNLOAD_TOKEN");
    
    const downloadTimer = window.setInterval(function () {
        const newToken = getCookie("DOWNLOAD_TOKEN");
        if (newToken != currentToken) {
            alert('ダウンロード終了!');
            window.clearInterval(downloadTimer);
        }
    }, 1000 );
});

// クッキー取得
function getCookie( name ) {
    var parts = document.cookie.split(name + "=");
    if (parts.length >= 2) return parts.pop().split(";").shift();
}

解説

サーバー側はDOWNLOAD_TOKENという名前で毎回ランダムな値をクッキーにセットする。クライアントはsubmit前にDOWNLOAD_TOKENの値を取得、submit後は1秒ごとにクッキーを監視する。DOWNLOAD_TOKENの値が変わったらダウンロード終了。

WebフォームでEnterキーを押したときにフォーカスを移動させたい

いやTabキー押せよ、って思うけど仕方ない。

やり方

$('form').on('keydown', 'input, button, select', function(e) {
    if (e.keyCode == 13) {
        if ($(this).attr("type") == 'submit') return;

        var form = $(this).closest('form');
        var focusable = form.find('input, button[type="submit"], select, textarea')
            .not('[readonly]').filter(':visible');

        if (e.shiftKey) {
            focusable.eq(focusable.index(this) - 1).focus();
        } else {
            var next = focusable.eq(focusable.index(this) + 1);
            if (next.length) {
                next.focus();
            } else {
                focusable.eq(0).focus();
            }
        }

        e.preventDefault();
    }
});

ポイント

textareaとsubmitボタンにフォーカスがある場合は移動せずに本来の動作(textareaなら改行、submitならsubmit)を行う。

ブラウザの戻るボタンを禁止したい

本当はそんなことしたくない。

なお、あくまで戻るボタンを封じるだけなので履歴を使うと戻れる。

環境

HTML5に対応したブラウザ

やり方

ページロードイベント中に以下のコードを追加。

history.pushState(null, null);
$(window).on('popstate', function(e) {
    history.pushState(null, null);
    alert('ブラウザの戻るボタンは使用できません');
});

説明

ポイントは戻るたびにpushStateで余分な履歴を一つ追加すること。これによって本来一つ前だった戻り先が押し出されて二つ前になるため、戻るボタンでは戻れなくなる。

Spring Securityで独自の認証を実装する

環境

Spring Boot 1.4

やりたいこと

ログイン画面で所属部署コード、ユーザーID、パスワードを入力してログイン。認証はDBやAPI等、何かしら独自のロジックで行う。

用意するクラス

ユーザー情報クラス

ユーザー情報を保持するためのクラス。認証に必要な項目だけではなく、氏名や部署名など付随する情報も持てるようにしておくとあとあと便利に使える。

public class User {
    private String id;
    private String name;
    private String deptCode;
    private String deptName;
    // 以下getter setter
}

AuthenticationFilter

認証のためのトークンを作成するクラス。まずはフォームから受け取ったパラメータでユーザー情報を作成。そしてユーザー情報とパスワードからトークンを作成し、後続の認証処理に渡す。

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

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        User user = new User();
        user.setId(request.getParameter("userId"));
        user.setDeptCode(request.getParameter("deptCode"));
        
        String password = obtainPassword(request);
        
        // トークンの作成
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(user, password);
        
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

AuthenticationProvider

実際に認証を行うクラス。受け取ったトークンからユーザー情報とパスワードを取得してなんらかの独自認証を行う。ロールの付与もここで行う。

import java.util.ArrayList;
import java.util.Collection;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

@Configuration
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication auth) throws AuthenticationException {
        User user = (User)auth.getPrincipal();
        String password = (String)auth.getCredentials();

        // ここで認証とロールの付与
        Collection<GrantedAuthority> authorityList = new ArrayList<>();
        if (user.getId().equals("admin")) {
            authorityList.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
        } else if (user.getId().equals("user"))
            authorityList.add(new SimpleGrantedAuthority("ROLE_USER"));
        else {
            throw new BadCredentialsException("Authentication Error");
        }

        return new UsernamePasswordAuthenticationToken(user, password, authorityList);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

supportsメソッドには受け取ったトークンが処理すべきものなのか判定する処理を書く。falseを返すとフレームワークは別の認証プロバイダーを探そうとするので、これを利用して複数の認証方法を提供することもできる。

設定

WebSecurityConfigurerAdapterを継承したクラスで設定を行う。

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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    CustomAuthenticationProvider authenticationProvider;
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/js/**", "/images/**", "/loginForm");
    }
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
            .logout()
                .logoutUrl("/logout").permitAll()
                .logoutSuccessUrl("/loginForm");

        CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
        filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/login", "POST"));
        filter.setAuthenticationManager(authenticationManagerBean());
        filter.setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler("/loginForm?error"));
        
        http.addFilterBefore(filter, CustomAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }
}

上記はログインフォームのURLが/loginForm、ログインのリクエストを受け取るURLが/loginとなるサンプル。ポイントは前述したCustomAuthenticationProviderCustomAuthenticationFilterをセットしているところ。

あとがき

DBで認証するならばAuthenticationProviderは自作せずにDaoAuthenticationProviderを使用するのが正攻法なのかもしれない。が、調べた感じだとむしろ面倒くさくなりそうではある。