在 Shiro 1.3.0 中实施散列密码身份验证时遇到问题

Trouble Implementing Hashed Passwords Authentication in Shiro 1.3.0

好的——我已经在这里转了一个星期试图自己解决这个问题,但我一无所获。那里的 Shiro documentation/tutorials 似乎在各种版本和过时的设计范例之间过于分散,以至于我很难弄清楚如何自己实现它。就连官方文档也留下了很多空白,让新手一头雾水。我一直在关注这个(现在有点过时了)关于在 JSF 应用程序中设置 Apache Shiro 身份验证的 BalusC 教程 (Link: http://balusc.blogspot.sg/2013/01/apache-shiro-is-it-ready-for-java-ee-6.html)。我能够通过 “散列密码” 部分跟进这篇文章,这是文章真正开始显示它的年龄的地方(声称不支持加盐),并驱使我看在其他地方实现这一重要功能。

下面是我的开发环境的基本情况。

IDE: NetBeans 8.0.2
服务器: TomEE 1.7.1
DB: MySQL 5.5
应用程序框架:
JSF 2.2
OmniFaces 1.8.1(还没有发挥作用,除了我使用 o:form 标签以期待进一步使用)
JPA 2.0(因为 TomEE 1.7.1 声称不支持 2.1,至少 NetBeans 是这么告诉我的...)
Apache DeltaSpike 1.2.1(仅核心和 JPA 模块;协助 CDI+JPA 持久性)
Apache Shiro 1.3.0-SNAPSHOT(仅限核心和 Web 模块)

我能够开始工作:
- 从 MySQL 数据库验证用户;读取 raw/plain-text 个用户名和密码
- 注册表格以raw/plain-text格式保存新用户到用户table

这里是相关的配置:
shiro.ini

[main]
# As per BalusC's guide for Ajax aware custom User Filter. Do not use @WebFilter annotation or web.xml to register this filter!
user = ds.nekotoba.filter.FacesAjaxAwareUserFilter
user.loginUrl = /faces/public/login.xhtml

#Define Realm
jdbcRealm = org.apache.shiro.realm.jdbc.JdbcRealm
jdbcRealm.permissionsLookupEnabled = true
jdbcRealm.authenticationQuery = SELECT password FROM users WHERE username = ?
jdbcRealm.userRolesQuery = SELECT role FROM userroles WHERE user_id = (SELECT id FROM users WHERE username = ?)

#Define Datasource
dataSource = com.mysql.jdbc.jdbc2.optional.MysqlDataSource
dataSource.serverName = localhost
dataSource.port = ####
dataSource.user = ********
dataSource.password = ********
dataSource.databaseName = ********
jdbcRealm.dataSource = $dataSource

#Add realm to securityManager
securityManager.realms = $jdbcRealm

[urls]
/faces/javax.faces.resource/** = anon
/faces/public/login.xhtml = user
/faces/public/app/** = user

用户创建方法 create()

public Long create(User user) {
    //Set created date
    user.setCreatedDate(new Date());

    //Persist to DB
    em.persist(user);
    return user.getId();
}

程序化用户登录方法(来自自定义 JSF 登录表单)login()

public void login() throws IOException {
    try {
        SecurityUtils.getSubject().login(new UsernamePasswordToken(username, password, remember));
        SavedRequest savedRequest = WebUtils.getAndClearSavedRequest(Faces.getRequest());
        Faces.redirect(savedRequest != null ? savedRequest.getRequestUrl() : Globals.HOME_URL);
    } catch (AuthenticationException ae) {
        Messages.addGlobalError("不明なユーザ、また試してみてください。");
        ae.printStackTrace();
    }
}

这一切都非常简单,而且有效——但是当我试图将所有这些转换为使用散列密码时,我的问题就来了。我能够通过修改后的创建方法获得散列密码,并将关联的盐保存到数据库中 — 但我似乎无法在我的 shiro.ini 中获得正确的设置以使用这些散列用户成功登录。

已更新 shiro.ini(以实施新的散列密码匹配)。请注意添加的 PasswordServicePasswordMatcher 和更新的 authenticationQuery,现在包括 salt 列。为了简洁起见,我只包含了 ini 文件的更新部分。

#Include salt column in authentication query
jdbcRealm.authenticationQuery = SELECT password, salt FROM users WHERE username = ?

#Use Default PasswordService/Matcher. This should use SHA-256, with 500,000 iteration hashing.
passwordMatcher = org.apache.shiro.authc.credential.PasswordMatcher
passwordService = org.apache.shiro.authc.credential.DefaultPasswordService
passwordMatcher.passwordService = $passwordService
jdbcRealm.credentialsMatcher = $passwordMatcher

更新了 create() 方法,该方法对密码进行哈希处理,并提取盐以单独存储在数据库中。

public Long create(User user) {
    HashingPasswordService hps = new DefaultPasswordService();
    //Hash password given in registration form using the DefaultPasswordService,
    //this should use the same defaults at the PasswordService/Matcher defined in shiro.ini
    Hash hash = hps.hashPassword(user.getPassword());
    //Set user.password to hashed version for persisting to DB
    String hashedPass = hash.toBase64();
    user.setPassword(hashedPass);
    //Get related salt from hash and set in user object for persisting to DB
    String salt = hash.getSalt().toBase64();
    user.setSalt("salt");

    //Set created date
    user.setCreatedDate(new Date());

    //Persist to DB
    em.persist(user);
    return user.getId();
}

这就是我卡住的地方——我假设此时我需要更新登录方法以通过从数据库中检索盐来处理用户输入的密码的散列,并对提交的密码进行散列和然后匹配数据库中存储的散列密码——但我尝试的所有尝试都没有成功。并尝试使用上面的 login() 方法进行身份验证只会给我一个错误,指出提供的凭据不匹配...

我已经尝试实现这个(日语)教程(日本语ガ少し知っテいますよ)中的一些代码,但我觉得有点不对劲(Link: http://d.hatena.ne.jp/apyo/touch/20120603/1338739210 ). Shiro不支持hashing/salting吗?我不明白为什么我必须 rewrite/override Shiro 方法(根据该教程)来完成对哈希数据的用户身份验证,这是框架声称支持的东西。我想我应该使用一些交付的方法,我只是找不到合适的方法。当然,可能是我 hashing/storing/retrieving 用户数据的方式导致了问题……我对框架太陌生了,不知道。任何帮助将不胜感激。

编辑 好的——所以看起来可能 "the norm" 必须重写交付的 JdbcRealm 中的一个方法来实现用户密码+salt 的验证。我已经实现了以下自定义领域,但是在调用该方法时得到了 NullPointerException

CustomJdbcRealm.java(基于上面的链接教程)

import java.util.List;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SaltedAuthenticationInfo;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.DefaultPasswordService;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.realm.jdbc.JdbcRealm;
import org.apache.shiro.util.ByteSource;

/**
 * @author mousouchop
 *
 * Modification Log: 2015-01-17 Original.
 */
public class CustomJdbcRealm extends JdbcRealm {
    @Inject
    private EntityManager em;

    /**
     * 認証情報を返却する。
     * @param token
     * @return 
     */
    @Override
    protected SaltedAuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
        throws AuthenticationException {

        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        SaltedAuthenticationInfo info = null;

        System.out.println("TEST######## TOKEN-UN: "+upToken.getUsername());

        // Here I switched out a plain JDBC query for a JPA query (Perhaps this is part of my problem?)
        List<String> queryResults = em.createNamedQuery("User.ps")
                .setParameter("username", username)
                .getResultList();
        System.out.println("TEST######## RESULT CT: "+queryResults.size());
        String password = queryResults.get(0);
        String salt = queryResults.get(1);
        System.out.println("TEST######## PW: "+password);
        System.out.println("TEST######## SLT: "+salt);

        // ShiroデフォルトのjdbcRealmをそのまま使い、SecurityManagerをshiro.iniで初期化する方法だと、
        // saltStyleが設定できない。saltをAuthenticationInfoに渡す方法は用意されているが、
        // DefaultPasswordServiceを使うとバグ?でsaltが無視されてしまう(まだ調査中です)
        // とりあえずブログ用に強引にゴリゴリとHashクラスを生成する。
        // ハッシュアルゴリズムが固定になってしまっているが、
        // まぁよしとする。別に動的に生成しなければいけない場面もないだろうし。
        Sha256Hash credentials = Sha256Hash.fromBase64String(password);
            credentials.setSalt(ByteSource.Util.bytes(Base64.decode(salt)));
        // SimpleHashクラスとDefautoPasswordServiceでハッシュ回数のデフォルト値が異なる。
        // 整合性が取れてないとも言えるが、これはこれでいいのかな。
    credentials.setIterations(DefaultPasswordService.DEFAULT_HASH_ITERATIONS);
        // principals,credentials,realm名を設定して、AuthenticationInfoを生成する。
        info = new SimpleAuthenticationInfo(username, credentials, getName());

        return info;
    }
}

在我拥有的 "TEST" 输出行中——第一个 returns 输入的用户名...我在其他任何输出行之前得到了 NPE。也许我会尝试在这个 class...

中使用普通的 JDBC 调用

好吧......我做了更多的挖掘,似乎我不能像我在上面尝试做的那样直接使用 CDI 注入 Shiro JDBC 领域......两者之间存在冲突管理 Shiro 使用它自己的 bean,而 CDI 使用它自己的。我确实发现 this answer 似乎试图通过初始化领域在 CDI 和 Shiro 之间架起一座桥梁,将 User DAO 从 CDI 插入领域,然后将领域实例绑定到 JNDI 以便稍后在 shiro.ini,然而,再一次,所有 examples/documentation 都太零散了,我无法完全让它工作。

因此,我只是逐字使用了我在问题中列出的日语教程中的自定义 JdbcRealm。我不想在我的 beans 中偏离 CDI 进行 DB 交互,但是因为 JDBC 数据源已经在 shiro.ini 中定义,所以所有连接信息和查询都从那里获取无需在自定义领域 hard-code/redefine 它们。我猜至少没有完全重复的代码...这是我最后的、工作的、自定义的 JdbcRealm bean,为了完整起见粘贴在这里:

CustomJdbcRealm.java

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SaltedAuthenticationInfo;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.DefaultPasswordService;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.realm.jdbc.JdbcRealm;
import org.apache.shiro.util.ByteSource;
import org.apache.shiro.util.JdbcUtils;

/**
 * @author mousouchop
 *
 * Modification Log: 2015-01-17 Original.
 */
public class CustomJdbcRealm extends JdbcRealm {

    /**
     * 認証情報を返却する。(Override AuthenticationInfo method)
     */
    @Override
    protected SaltedAuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
            throws AuthenticationException {

        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        Connection conn = null;
        SaltedAuthenticationInfo info = null;

        try {
            conn = dataSource.getConnection();
            // DBよりパスワード、SALTを取得する。(Get password and salt from DB)
            String[] queryResults = getPasswordForUser(conn, username);
            String password = queryResults[0];
            String salt = queryResults[1];

            // ShiroデフォルトのjdbcRealmをそのまま使い、SecurityManagerをshiro.iniで初期化する方法だと、
            // saltStyleが設定できない。saltをAuthenticationInfoに渡す方法は用意されているが、
            // DefaultPasswordServiceを使うとバグ?でsaltが無視されてしまう(まだ調査中です)
            // とりあえずブログ用に強引にゴリゴリとHashクラスを生成する。
            // ハッシュアルゴリズムが固定になってしまっているが、
            // まぁよしとする。別に動的に生成しなければいけない場面もないだろうし。
            Sha256Hash credentials = Sha256Hash.fromBase64String(password);
            credentials.setSalt(ByteSource.Util.bytes(Base64.decode(salt)));
            // SimpleHashクラスとDefautoPasswordServiceでハッシュ回数のデフォルト値が異なる。
            // 整合性が取れてないとも言えるが、これはこれでいいのかな。
            credentials.setIterations(DefaultPasswordService.DEFAULT_HASH_ITERATIONS);
            // principals,credentials,realm名を設定して、AuthenticationInfoを生成する。
            info = new SimpleAuthenticationInfo(username, credentials, getName());
        } catch (SQLException e) {
            final String message = String.format("次のユーザーの認証中にSQLエラーが発生しました。ユーザー:[%s]%n", username);
            throw new AuthenticationException(message, e);
        } finally {
            JdbcUtils.closeConnection(conn);
        }
        return info;
    }

    /**
     * DBよりユーザーに紐づくパスワードとSALTを取得する。 org.apache.shiro.realm.jdbc.JdbcRealmよりコピー。
     * saltStyleに関する記述を除去。
     *
     * @param conn
     * @param username
     * @return
     * @throws SQLException
     */
    private String[] getPasswordForUser(Connection conn, String username) throws SQLException {

        String[] result;
        result = new String[2];
        PreparedStatement ps = null;
        ResultSet rs = null;
        try {
            // 親クラスにデフォルトで用意されているSQLか、
            // shiro.iniにもパスワード取得用SQLを定義できる。
            ps = conn.prepareStatement(authenticationQuery);
            ps.setString(1, username);
            rs = ps.executeQuery();

            // 複数レコードが取れた場合のチェックフラグ
            boolean foundResult = false;
            while (rs.next()) {

                // ループ2周めだとfoundResultはtrue=エラー
                if (foundResult) {
                    throw new AuthenticationException(
                            String.format("ユーザー:%sのデータが複数あります。ユーザー名に対応するユーザーは必ず一人でないといけません。%n", username));
                }

                // password
                result[0] = rs.getString(1);
                // password_salt
                result[1] = rs.getString(2);
                foundResult = true;
            }
        } finally {
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(ps);
        }
        return result;
    }
}

所以,这连同我上面问题中的 updated shiro.inicreate() 方法应该允许您使用 Apache Shiro 1.2 实现散列用户身份验证.*/1.3.0-SNAPSHOT.