ラベル Java の投稿を表示しています。 すべての投稿を表示
ラベル Java の投稿を表示しています。 すべての投稿を表示

2017年9月14日木曜日

Spring BootとSpring Securityでログインを実装する方法

Spring Bootな構成でSpring Securityを用いて一般的なログイン機構を実装する方法をまとめる。ここでは、フロントをjavascriptで、サーバー側はJSONを返すAPIとして実装するような構成を想定する。また、認証情報はRDBMS上のユーザー情報テーブルにて管理する。

Spring Securityの機能全般を有効にする

まずは、WebSecurityConfigurerAdapterを継承したクラスを作成し、@EnableWebSecurityアノテーションを付与してSpring Securityの機能を有効化する。

    
@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    // 略
}

Spring Securityの設定

これらの設定は、WebSecurityConfigurerAdapterのconfigure(HttpSecurity)メソッドをオーバーライドして実装する。ここでのポイントは4つ。

  • CSRF対策を無効化(今回は無関係なので無効化しているけど、別途CSRF対策は設定すべし)
  • ログイン認証を行うパスを設定
  • フォーム認証を有効化
  • POST /loginでログイン処理がトリガーされる(カスタマイズすることも可能)

@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * {@inheritDoc}
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //
        // CSRF対策を無効化
        //
        http.csrf().disable();

        //
        // ログイン認証を行うパスを設定
        //
        http.authorizeRequests()
            // ログイン無しでアクセス許可するパス
            .antMatchers("/").permitAll()
            // その他はログインが必要
            .anyRequest().authenticated();

        //
        // フォーム認証を有効化
        //
        http.formLogin()
    }
}

ログインが成功/失敗した場合の処理を実装

ログインが成功/失敗した場合に、それぞれJSON形式のレスポンスを返すように実装する。

まずは、ログイン成功のハンドラー(AuthenticationSuccessHandler)とログイン失敗のハンドラー(AuthenticationFailureHandler)を定義する。


/**
 * ログイン成功時の動作を定義
 */
private static final AuthenticationSuccessHandler LOGIN_SUCCESS = (req, res, auth) -> {
    // HTTP Statusは200
    res.setStatus(HttpServletResponse.SC_OK);

    // Content-Type: application/json
    res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);

    // Body
    res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code", "login_success")));
    res.getWriter().flush();
};

/**
 * ログイン失敗時の動作を定義
 */
private static final AuthenticationFailureHandler LOGIN_FAILED = (req, res, auth) -> {
    // HTTP Statusは401
    res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    // Content-Type: application/json
    res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);

    // Body
    res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code", "login_failed")));
    res.getWriter().flush();
};

ここで使っているImmutableMapは、Guavaライブラリのクラス。JsonUtilはObjectMapperを利用するための自作ユーティリティクラスとする。ObjectMapperは、別途DIコンテナに登録しておいたものを使うのが良さそうだが、ここでは主題ではないため適当に下記のように実装しておく。

public class JsonUtil {
    public static String encode(Object src) {
        try {
            return new ObjectMapper().writeValueAsString(src);
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }
}

最後に、前項で設定したフォーム認証の有効化の箇所へ、ハンドラーの紐付け設定を追記する。

@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * {@inheritDoc}
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 中略

        //
        // フォーム認証を有効化
        //
        http.formLogin()
            //
            // ログイン成功のハンドラーを設定
            //
            .successHandler(LOGIN_SUCCESS)
            //
            // ログイン失敗のハンドラーを設定
            //
            .failureHandler(LOGIN_FAILED);
    }
}

未ログインアクセスの制御

このままだと、ログインが必要なURL(例えば/test)にアクセスすると、/loginへのリダイレクトがレスポンスされる。Ajaxな通信を行う前提なので、ログインが必要な旨を示すJSONを返すように実装する。

まずは、認証エントリーポイントのハンドラー(AuthenticationEntryPoint)を定義する。

/**
 * 認証エントリポイントの動作を定義
 */
private static final AuthenticationEntryPoint LOGIN_REQUIRED = (req, res, auth) -> {
    // HTTP Statusは401
    res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    
    // Content-Type: application/json
    res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);

    // Body
    res.getWriter().write(JsonUtil.encode(ImmutableMap.of("code", "login_required")));
    res.getWriter().flush();
};

次に、前項と同様にconfigure()メソッドの中で、ハンドラーの紐付け設定を追記する。

@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * {@inheritDoc}
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 中略

        http.exceptionHandling()
            //
            // 要ログインページアクセスのハンドラーを設定
            //
            .authenticationEntryPoint(LOGIN_REQUIRED);
    }
}

ユーザー情報を取得するサービスを定義

最後のステップとして、データベースからユーザー情報を取得して認証する部分を実装する。

まずは、データベースからユーザー情報を取得するサービスをUserDetailsServiceインターフェースの実装クラスとして定義する。

※実際には、JdbcTemplateではなくMyBatisやらDBFluteやらのお好きなO/Rマッパーを利用すると思う。

/**
 * ユーザー情報を取得するサービス
 */
@Service
public class MyUserDetailsService implements UserDetailsService {
    private final JdbcTemplate jdbcTemplate;

    private static final String SQL
        = "select password from user where name = ?";

    private static final SimpleGrantedAuthority ROLE
        = new SimpleGrantedAuthority("ROLE_USER");
    
    /**
     * Constructor
     */
    public MyUserDetailsService(final JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (StringUtils.isEmpty(username)) {
            throw new UsernameNotFoundException("No username");
        }

        // データベースから該当ユーザー情報を取得
        final String password = jdbcTemplate.queryForObject(
                SQL, new Object[]{username}, String.class);
        
        if (StringUtils.isEmpty(password)) {
            throw new UsernameNotFoundException("No user");
        }

        // ユーザー情報を生成
        return new User(
                username,
                password,
                Collections.singleton(ROLE));
    }
}

続いて、AuthenticationManagerBuilderへ、ユーザー情報取得サービスの紐付け設定を行う。

@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    private final MyUserDetailsService service;

    /**
     * Constructor
     */
    public MySecurityConfig(final MyUserDetailsService service) {
        this.service = service;
    }

    // 中略

    /**
     * {@inheritDoc}
     */
    @Autowired
    void configureAuthenticationManager(AuthenticationManagerBuilder auth) throws Exception {
        //
        // ユーザー情報取得サービスを紐付ける
        //
        auth.userDetailsService(service);
    }
}

GET /testの動作

要ログインURLへのアクセスは、ログインが必要な旨を示すJSONレスポンスが帰ってくる。

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Thu, 14 Sep 2017 14:24:14 GMT
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=8A504C961F44779D571088EBFE5E7BA3; Path=/; HttpOnly
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block

{code: "login_required"}

POST /loginの動作(ユーザー名やパスワードが正しくない)

所謂、ログインが失敗するパターン。

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Thu, 14 Sep 2017 14:29:08 GMT
Expires: 0
Pragma: no-cache
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block

{code: "login_failed"}

POST /loginの動作(ユーザー名とパスワードが正しい)

ログインが成功するパターン。さらっと流したが、ログイン処理のデフォルト実装は、application/x-www-form-urlencodedなリクエストで、ユーザー名をusername、パスワードをpasswordという名前のパラメータとして送信する必要がある。

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
Date: Thu, 14 Sep 2017 14:35:07 GMT
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=DA1076E9F2247B35BF5718CC75B9979B; Path=/; HttpOnly
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Xss-Protection: 1; mode=block

{code: "login_success"}

2011年6月26日日曜日

Java SE 7で何が変わるのか

来る2011年7月28日は、5年ぶりのJavaアップデート予定日。次期バージョンのJava SE 7にはいくかの新しい要素が含まれているので、先日のJJUG Cross Community Conferenceの振り返りを兼ねてまとめておく。

Java SE 7の主なトピックは以下の通り。

  • Project Coin (JSR 334)
  • NIO.2(JSR 203)
  • Fork/Join Framework
  • Invoke Dynamic (JSR292)

Project Coin

Project Coinとは、言語仕様な小さな変更を行うプロジェクト。

switch文でStringが使えるようになる

public void execute(String state)
{
  switch (state)
  {
  case "ready":
    startSomething();
    break;

  case "running"
    finishJob();
    brak;
  }
}

数値表現形式の追加

バイナリ表記(0bで書き始める)が追加された。

  • 1 (10進数表記)
  • 01 (8進数表記)
  • 0x1 (16進数表記)
  • 0b1 (バイナリ表記)

可読性向上を目的としたアンダースコア表記が追加された。

// クレカ番号など?
long data = 1234_5678_9012L;

マルチキャッチ

数種の例外処理をまとめることが可能になった。

try
{
  doSomething();
}
catch (IOException | SqlException e)
{
  e.printStackTrace();
}

例外再送の記述を簡易化

catch句にfinalを付けることで、メソッド定義のthrows部が不要になる。

public void execute() // throws IOException が不要
{
  try
  {
    doSomething();
  }
  catch (final IOException e)
  {
    throw e;
  }
}

Genericsインスタンス生成時の型推論強化

右辺では<>のみ記載すればOKになった。

// 今までの書き方
Map<String, String> data = new HashMap<String, String>();

// これからの書き方
Map<String, String> data = new HashMap<>();

try構文におけるリソース解放機能の追加

C#で言うところのusing的な感じ。自動的にclose()メソッドがコールされる。

try ( InputStream is = new FileInputStream("/tmp/data.txt") )
{
  doSomething();
}
catch (IOException e)
{
  e.printStackTrace();
}

2011年6月3日金曜日

JavaでOAuthをやっつける方法

前回、OAuthについてまとめてみたので、今度は実際にOAuth認証を利用したAPIへのアクセスをJavaで実装してみる。

今回は、天下のoauth.netのoauthライブラリ(Google Code)を使う。

oauthのリポジトリからcommons、consumer、httpclient4を持ってくる。

OAuthのライブラリはいくつかあるみたいだけど、Javaのコードを書く場合はAndroidアプリやGoogle App Engineなどにも移植可能なものがよさげ。
(Jakarta Commons HttpClient v4にてOAuth認証を組み込むとコードがすっきりする。一方、GAEではHttp Client v4が使えなかったりする。こういった違いを吸収できるとこがイイ)


やること

いっぱい。。。

  • consumer.propertiesを用意
  • consumer.propertiesを読み込む
  • リクエストトークンを取得する
  • ユーザーにアプリケーションを承認してもらう
  • アクセストークンを取得する

consumer.propertiesを用意

まず、サービスプロバイダーのエンドポイント情報やコンシューマの情報を、下記のようなconsumer.propertiesファイルに定義する。(適当な名前を付ける。例ではtomokey)

tomokey.consumerKey: consumer_key
tomokey.consumerSecret: consumer_secret
tomokey.callbackURL: oob
tomokey.serviceProvider.baseURL: https://www.google.com/
tomokey.serviceProvider.requestTokenURL: /accounts/OAuthGetRequestToken
tomokey.serviceProvider.userAuthorizationURL: /accounts/OAuthAuthorizeToken
tomokey.serviceProvider.accessTokenURL: /accounts/OAuthGetAccessToken
この設定内容は、各サービスプロバイダーや利用形態で異なる。

Google Data APIをWebアプリケーションから利用する場合

consumer key発行されたもの
consumer secret発行されたもの
callback URLverifierを受け取るURL
request token URLhttps://www.google.com/accounts/OAuthGetRequestToken
user authorization URLhttps://www.google.com/accounts/OAuthAuthorizeToken
access token URLhttps://www.google.com/accounts/OAuthGetAccessToken

Google Data APIをクライアントアプリケーションから利用する場合

consumer keyanonymous
consumer secretanonymous
callback URLoob
request token URLhttps://www.google.com/accounts/OAuthGetRequestToken
user authorization URLhttps://www.google.com/accounts/OAuthAuthorizeToken
access token URLhttps://www.google.com/accounts/OAuthGetAccessToken

consumer.propertiesを読み込む

// クラスローダーを生成
ClassLoader loader = ClassLoader.getSystemClassLoader();
// 設定ファイルのURLを取得
URL url = loader.getResource("consumer.properties");
// 読み込み
Properties prop = ConsumerProperties.getProperties(url);

// tomokeyという名前の付いた設定を読み込む
ConsumerProperties consumers = new ConsumerProperties(prop);
OAuthConsumer consumer = consumers.getConsumer("tomokey");

リクエストトークンを取得する

// OAuthClient
OAuthClient client = new OAuthClient(new HttpClient4());

// OAuth accessor
OAuthAccessor accessor = new OAuthAccessor(consumer);

// パラメータを構築
List<Parameter> params = new ArrayList<Parameter>();
// oauth_callbackを追加
params.add(new Parameter(
    OAuth.OAUTH_CALLBACK,
    accessor.consumer.callbackURL));

// リクエストトークン取得を要求
client.getRequestToken(accessor, "GET", params);

// 取得したリクエストトークン
String token = accessor.requestToken;
String secret = accessor.tokenSecret;
ライブラリを使っているおかげでだいぶ簡潔になっているけど、本当は色々とやってくれている。具体的には、下記のことをやっている。
base stringの構築
これらの文字列を全部連結する。
  • HTTPリクエストメソッド(POSTとかGET)
  • アクセスURL
  • 全リクエストパラメータを「key=value」形式にしてkeyでソートして「&」でくっつける…
oauth_signatureの構築
デフォルトのSHA-1アルゴリズムでbase stringに対するハッシュ値を計算し、base64エンコードをかけて生成する。
SHA-1の鍵にはconsumer secretの末尾に「&」を付与したものを用いる。
HTTPリクエストの構築と実行
oauth_signatureを含む全てのパラメータをBODY部に設定し、Content-Type:application/x-www-form-urlencodedとしてリクエストを発行する。
APIに依っては、パラメータをHEADER部に行わなければならない場合もあるので、consumer.propertiesに「tomokey.consumer.parameterStyle: AUTHORIZATION_HEADER」とか設定すると吉。


ユーザーにアプリケーションを承認してもらう

これはアプリケーションの形態に依存すると思う(Androidアプリならhttp/httpsのインテントを投げるとか)けど、いずれにしても下記のように作成したURLに対して、ブラウザでアクセスしてもらう必要がある。

// サービスプロバイダーが定義しているエンドポイント
String authUrl
  = accessor.consumer.serviceProvider.userAuthorizationURL;

// ここで作成したURLにアクセスしてもらう
String url
  = OAuth.addParameters(authUrl, OAuth.OAUTH_TOKEN, token);
大抵の場合は承認ボタンがあるのでポチっとしてもらう。その後、Webアプリケーション(callbackURLが設定されている)の場合は、そのURLへのアクセス時にパラメータとしてverifierが送信される。(リダイレクトされる)

クライアントアプリケーション(callbackURLがoob)の場合は、画面にverifierが表示される。(ユーザーにコピペしてもらう必要がある)


アクセストークンを取得する

// OAuthClient
OAuthClient client = new OAuthClient(new HttpClient4());

// OAuth accessor
OAuthAccessor accessor = new OAuthAccessor(consumer);

// SHA1の鍵にリクエストトークンのシークレットのほうが必要
accessor.tokenSecret = secret;

// パラメータを構築
List<Parameter> params = new ArrayList<Parameter>();
// oauth_tokenとしてリクエストトークンを追加
params.add(new Parameter(
    OAuth.OAUTH_TOKEN,
    token));
// oauth_verifierを追加
params.add(new Parameter(
    OAuth.OAUTH_VERIFIER,
    "ベリファイア"));

// アクセストークン取得を要求
client.getAccessToken(accessor, "GET", params);

// 取得したアクセストークン
String accToken = accessor.accessToken;
String accSecret = accessor.tokenSecret;
リクエストトークンの取得と似ているけど、注意点がいくつかある。
  • accessor.tokenSecretにリクエストトークンのシークレットのほうを設定する。(SHA1ハッシュ計算時に使う鍵として利用)
  • リダイレクトかコピペかで入力してもらったベリファイアをパラメータとして設定する。

終わり

一応、アクセストークンを使ってAPIアクセスするコードものっけておく。

// URL(Fusion Tables API)
String url = "https://www.google.com/fusiontables/api/query";

// パラメータ
List<Parameter> params
  = OAuth.newList("sql", "SELECT * FROM 913439");

// OAuthClient
OAuthClient client = new OAuthClient(new HttpClient4());

// OAuth accessor
OAuthAccessor accessor = new OAuthAccessor(consumer);

// アクセストークンを設定
accessor.accessToken = accToken;
accessor.tokenSecret = accSecret;

// リクエストオブジェクト生成
OAuthMessage request
  = accessor.newRequestMessage("POST", url, aParams);

// 実行
OAuthMessage response
  = client.access(request, ParameterStyle.AUTHORIZATION_HEADER);
但し、リダイレクトの考慮をしていないので注意。

ライブラリにして公開した

すぐ上に書いてあるリダイレクトの考慮をした上で、ちょっと便利っぽくライブラリにまとめてみた。 ここ(Google Code)からダウンロード可能。