Apache Shiro和Spark Framework
首先
最近我自己开始重新评估Java及其生态系统。特别是Apache系列的项目相对历史悠久,也很稳定。发现Apache Zeppelin在认证方面采用了Apache Shiro,觉得很有趣。为了给Java的微框架SparkFramwork添加认证功能,我把认证和授权框架Apache Shiro整合了进去,以下是相关的记录。
0.1 最后总结
-
- SparkFrameworkとApache Shiro統合する場合は、SparkFramework内蔵Jettyではなく、自前でJettyをセットアップしShiroのイベントリスナーとSparkFrameworkを追加する
-
- URLリライトセッションは無効にする
- 固定セッション攻撃対策をする場合は、Apache Shiroの認証フィルタをカスタマイズする
1. 动机
-
- 毎回、自分で認証・認可まわりを実装するのが面倒。セキュリティは自分で実装するのはリスクも高い。
-
- ID/PWだけでなくLDAPとかの認証方式を、サクッと利用できるようにしたい。
- Apacheプロジェクト色々見て回っていて、Apache Shiroを使ってみたかった。
2. 准备
这次实现使用了Java11版本。
2.1 构建.gradle文件
//中略
dependencies {
//後々、SparkFrameworkにApache Shiroを統合させる
implementation 'com.sparkjava:spark-core:2.9.3'
//テンプレートエンジンはなんでもいいが今回はThymeleaf
implementation 'com.sparkjava:spark-template-thymeleaf:2.7.1'
//この辺は本筋ではないDIやDBまわり
implementation group: 'com.google.inject', name: 'guice', version: '5.0.1'
implementation group: 'com.h2database', name: 'h2', version: '1.4.200'
implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.0'
//これも本筋ではない。ログまわりの依存。Apache Shiroがcommons.loggingを要求するので、jcl-over-slf4j入れている
implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.14.1'
implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.14.1'
implementation group: 'org.slf4j', name: 'jcl-over-slf4j', version: '1.7.32'
//Apache Shiro本体とWebへ統合する機能
implementation group: 'org.apache.shiro', name: 'shiro-core', version: '1.8.0'
implementation group: 'org.apache.shiro', name: 'shiro-web', version: '1.8.0'
}
这个项目依赖于Apache Shiro。为了从数据库中获取ID/PW,我们还依赖于H2 Database和连接池HikariCP。
准备2.2数据库
提前在数据库中创建一个具有用户名、密码和角色的表。在这里,为了简单起见,将角色设置为相同的表,但由于角色可以设置为多个,也可以将其放在另一个表中。
CREATE TABLE User (
name VARCHAR(128) PRIMARY KEY,
passwordHash VARCHAR(128),
role VARCHAR(128)
);
3. Apache Shiro的配置和执行
3.1 创建shiro.ini配置文件
可以通过 shiro.ini 这个配置文件来进行控制。在本例中,通过数据库(H2)中的用户名和密码哈希进行认证,以及通过 ini 文件中明文写入的用户名和密码进行认证。
[main]
# iniファイルによる認証設定 だがデフォルトでオンになるので不要
#iniRealm = org.apache.shiro.realm.text.IniRealm
#iniRealm.resourcePath = classpath:shiro.ini
# データベースによる認証設定
# ID/PWを問い合わせるHikariCPのデータソースを設定
ds = com.zaxxer.hikari.HikariDataSource
ds.username = sa
ds.password = ""
ds.driverClassName = org.h2.Driver
ds.jdbcUrl = jdbc:h2:./tmp/testdb;MODE=MySQL
# デフォルトのパスワードサービス(SHA256でハッシュ化する)
passwordService = org.apache.shiro.authc.credential.DefaultPasswordService
passwordMatcher = org.apache.shiro.authc.credential.PasswordMatcher
passwordMatcher.passwordService = $passwordService
# JDBCで認証情報取得する設定。SQL文を書いておく。
dbRealm = org.apache.shiro.realm.jdbc.JdbcRealm
dbRealm.dataSource = $ds
dbRealm.authenticationQuery = SELECT passwordHash FROM User WHERE name = ?
dbRealm.userRolesQuery = SELECT role from User WHERE name = ?
dbRealm.credentialsMatcher = $passwordMatcher
# このように2つのレルムを並べて、どちらかで認証することも可能。
securityManager.realms = $dbRealm,$iniRealm
# iniファイルには平文でユーザ名=パスワード,ロールを書ける。
[users]
admin = admin,adminRole
# ロールとバーミッションの設定
[roles]
adminRole = *
在[主要]部分配置多个域。
在[用户]和[角色]部分中,可以直接以明文形式写入用户、角色和权限,因此在测试时不想连接到数据库时,可以将其写在这里(相反,在运营时,我认为不应该将ini的域写在这里)。
3.2 对于独立应用程序而言
import org.apache.shiro.env.BasicIniEnvironment;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UsernamePasswordToken;
public class App{
public static void main(String... args) throws IOException {
final var env = new BasicIniEnvironment("classpath:shiro.ini");
final var sm = env.getSecurityManager();
SecurityUtils.setSecurityManager(sm);
final var subject = SecurityUtils.getSubject();
final var username = "admin";
final var password = "admin";
try {
subject.login(new UsernamePasswordToken(username,password));
}catch(AuthenticationException e){
System.out.println("ログイン失敗");
}
if (subject.isAuthenticated()) {
System.out.println("認証OK、処理実行");
} else {
System.out.println("未認証");
}
}
}
在这种情况下,对于独立应用程序(多个用户不能同时使用的应用程序),如上所述。
Subject表示用户,通过将UsernamePasswordToken传递给login方法进行身份验证。如果验证失败,则抛出AuthenticationException的子类异常。
不需要详细解释登录失败的原因,因此不需要细致地捕获。
将其整合到SparkFramework中
这有点麻烦。Spark框架是一个可以轻松创建REST API的框架,内置Jetty应用服务器,在Servlet上运行。
为了将Shiro添加到项目中,需要注册事件监听器和过滤器,但是由于SparkFramework无法干预设置的Jetty服务器,所以需要自己设置内置的Jetty服务器。
4.1 安装Jetty服务器
我們可以準備WEB-INF/web.xml檔案並將其放在Jetty容器上作為WAR應用,但由於已經有內建伺服器了,因此我們決定使用Java編寫設定。
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.eclipse.jetty.webapp.WebAppContext;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.DefaultServlet;
import org.apache.shiro.web.servlet.ShiroFilter;
import org.apache.shiro.web.env.EnvironmentLoaderListner;
import javax.servlet.DispatcherType;
import spark.servlet.SparkFilter;
import java.util.EnumSet;
public final class JettyServer {
public static void start(){
//サーバの設定
final var threadPool = new QueuedThreadPool(8,4,10000);
final var server = new Server(threadPool); //サーバ
final var httpConfig = new HttpConfiguration();
final var httpConnectionFactory = new HttpConnectionFactory(httpConfig);
final var serverConnector = new ServerConnector(server,httpConnectionFactory);
serverConnector.setPort(4567); //ポート
final ServerConnector[] connectors = {serverConnector};
server.setConnectors(connectors);
//WebAppContext作成
final var context = new WebAppContext();
context.setContextPath("/");
//URLリライトセッションの無効化
context.setInitParameter("org.eclipse.jetty.servlet.SessionIdPathParameterName","none");
//Apache Shiroの設定
final var shiroFilterName = ShiroFilter.class.getCanonicalName();
context.addFilter(shiroFilterName, "/*", EnumSet.of(
DispatcherType.REQUEST,
DispatcherType.INCLUDE,
DispatcherType.ERROR,
DispatcherType.FOWRARD,
DispatcherType.ASYNC));
context.addEventListner(new EnvironmentLoaderListner());
//Spark Frameworkの追加
final var sparkFilter = new FilterHolder();
sparkFilter.setName("SparkFilter");
sparkFilter.setClassName = SparkFilter.class.getCanonicalName();
//SparkApplicationクラスを指定する。
sparkFilter.setInitParameter("applicationClass",
MyApplication.class.getCanonicalName());
context.addFilter(sparkFilter,"/*",EnumSet.of(DispatcherType.REQUEST));
//サーバにWebbAppContextをセット
context.setResourceBase("./src/main/resources");
server.setHandler(context);
}
}
流下的河流变长了,但情况如下。
-
- Jettyサーバを作成
-
- サーバーコネクターを作成し、Jettyサーバにセット
-
- Webアプリケーションコンテキストを作成
-
- URLリライトセッションを無効化
-
- コンテキストのフィルターにApacheShiroフィルターを追加(これで、Shiroの認証認可が走る)
-
- ApacheShiroが設定ファイルを読むイベントリスナーを登録
-
- コンテキストのフィルターにSparkFrameworkのフィルターを追加(これでSparkFrameworkが使えるようになる)
-
- SparkFrameworkアプリケーションのクラス名を指定
- JettyサーバーにWebアプリケーションコンテキストをセット
在Sparkframework之前添加Apache Shiro的过滤器。
创建4.2 Spark应用程序
如果不使用内置的Jetty服务器,则需要创建Spark Application类。在之前设置Spark筛选器时,将该类作为”applicationClass”来进行设置。
import spark.servlet.SparkApplication;
import static spark.Spark.*;
import spark.TemplateEngine;
import spark.template.thymeleaf.ThymeleafTemplateEngine;
public class MyApplication implements SparkApplication {
@Override
public void init() {
staticFileLocation("/public");
//テンプレートエンジン。本来はDIとかで突っ込みThymeleafには依存しないようにする
final var engine = new ThymeleafTemplateEngine();
//loginはGET/POST共に、ログインページを表示するように
get("/login",(req,res) -> {
final var subject = SecurityUtils.getSubject();
final var isAuth = subject.isAuthenticated();
return new ModelAndView(Map.of("isAuth",isAuth),"login");
},engine);
post("/login",(req,res) -> {
final var subject = SecurityUtils.getSubject();
final var isAuth = subject.isAuthenticated();
return new ModelAndView(Map.of("isAuth",isAuth),"login");
},engine);
get("/", (req,res) -> new ModelAndView(
Map.of("username",req.raw().getRemoteUser()),"index"),engine);
get("/adminonly",(req,res) -> "管理者専用です"); //管理者ロール専用URL
get("/unauthorized"),(req,res) -> "未認可です!"); //未認可の時のURL
}
@Override
public void destory() {
//アプリケーション終了時の処理などを記述する。
}
}
确保/login显示登录页面。为需要身份验证的URL(根上下文)提供/(根上下文)。为需要进行身份验证和授权的URL提供/adminonly。
4.3 shiro.ini的配置
在shiro.ini中添加以下配置。
[main]
# Realm設定は略
# セッションマネージャ(デフォルトこれなので明示的に書く必要はないが)
sessionManager = org.apache.shiro.web.session.mgt.ServletContainerSessionManager
securityManager.sessionManager = $sessionManager
# ログインするURL
authc.loginUrl = /login
# ログイン成功時の遷移先
authc.successUrl = /
# ログイン時のパラメータ名
authc.usernameParam = username
authc.passwordParam = password
authc.rememberMeParam = rememberMe
# ログアウト時のリダイレクト先
logout.redirectUrl = /login
# 未認可の時の遷移先
roles.unauthorizedUrl = /unauthorized
[urls]
# URLに認証フィルタを設定する
/ = authc
/login = authc
/logout = logout
/adminonly = authc,roles[admin]
[users]
admin = adminpass,admin,user
user1 = user1pass,user
[roles]
admin = *
user = readOnly
主要的过滤器。Shiro还准备了其他几个。
创建登录页面。
创建Thymeleaf模板。
<html lang="ja">
<head>
<meta charset="utf-8">
</head>
<body>
<th:block th:if="${isAuth != true}">
<form action="/login" method="post">
ユーザ名:<input type="text" name="username"><br>
パスワード:<input type="password" name="password"><br>
RmemeberMe:<input type="checkbox" name="rememberMe" value="false"><br>
<input type="submit" value="ログイン">
</form>
</th:block>
<th:block th:if="${isAuth == true}">
既にログイン済みです。ログアウトする場合はこちら<br>
<a href="/logout">ログアウト</a>
</th:block>
</body>
</html>
在Shiro.ini文件中,用户名、密码和rememberMe的name属性应与设定的值相匹配。
创造一个4.5级的网站
<html lang="ja">
<head><meta charset="utf-8"></head>
<body>
ようこそ<span th:text="${username}"></span>さん
</form>
</body>
</html>
认证页面。
5. 执行
public class App {
public static void main(String... args){
JettyServer.start();
}
}
5.1 验证认证
-
- 不正なユーザID・PWでログインする ⇨ /loginにリダイレクトする
-
- 未認証のまま/にアクセスする ⇨ /loginにリダイレクトする
-
- 正規のユーザID・PWでログインする ⇨ /にリダイレクトする
- ログアウト(/logoutに遷移)した後、/にアクセスする ⇨ /loginにリダイレクトする
5.2 确认审核是否被批准。
-
- user1でログインし、/adminonlyにアクセスする ⇨ /unauthorizedにリダイレクトする
- adminでログインし、/adminonlyにアクセスする ⇨ アクセスできる。
关于会议
6.1 关于会话管理器
在Apache Shiro中,为Web应用程序提供了两种会话管理器。
-
- DefaultWebSessionManager
Apache Shiroのネイティブのセッション管理機能を用いる。
セッションの有効期限などは、独自に設定する。
Servletコンテナ(JettyとかTomcat)に依存しないので、移植性が高い
(と言っているが、そもそもServletコンテナ以外でApache Shiroを使うのかは疑問。PlayFrameworkとか?うーん・・・)
ServletContainerSessionManager
サーブレットコンテナのセッション管理機能を用いる。
デフォルトはこれで、要はServletでいつも使っているセッション
セッションの有効期限なども、サーブレットコンテナ側の設定に依る。(Jettyならデフォルト30分)
6.2 禁用URL重写(会话劫持缓解措施)
如果使用ServletContanerSessionManager,Jetty的会话无法正常工作,除非在init参数中禁用URL重写,如下所示。
这个是禁用URL重写的,但是我认为现在很少有不接受Cookie的浏览器,而且URL中包含会话ID会增加会话劫持的风险,所以原则上应该禁用。
context.setInitParameter("org.eclipse.jetty.servlet.SessionIdPathParameterName","none");
6.3 固定会话攻击缓解措施
很遗憾,Apache Shiro的登录功能(登录过滤器)会在认证成功后继续使用之前的会话ID。
固定会话攻击是攻击者利用某种方式使他人使用固定的会话ID进行登录,进而进行会话劫持的攻击手法。
根据国际语音协会的《安全网站建设指南》,可以采取以下措施:登录成功后,应重新生成会话ID。
成功登录后,启动新的会话。一些网络应用程序在用户登录之前的阶段(例如开始浏览网站时)就会发放会话ID并启动会话,并继续在登录后使用该会话的实现方式也存在。然而,这种实现方式可能对固定会话ID攻击具有弱点。为了避免这种实现方式,我们将从登录成功时开始启动新的会话(使用新的会话ID进行会话管理)。同时,在启动新会话时,我们将使旧的会话ID无效化(*3)。这样一来,恶意人士将无法通过事先获得的会话ID访问用户新登录的会话。
作为对策,可以在登录后启动会话,或者在登录后更改会话ID。
当我在Google先生上搜索”Apache Shiro Session Fixation Attack”时,我找到了大约5年前的JIRA问题。问题中记录了一个自定义过滤器的示例,该过滤器会复制旧会话属性并启动一个新会话。
那个事情有点麻烦,但是从Servlet3.1开始有一个可以改变会话ID的功能,所以我决定使用它来试试看。
所以,我会稍微修改现有的FormAuthenticationFilter,创建一个自定义的认证过滤器。
实际上,只是在登录后改变会话ID而已。(changeSessionId()是Servlet3.1及以上版本的功能)
创建自定义表单认证过滤器
package my.filter;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.authc.AuthenticationToken;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
public class MyFormAuthFilter extends FormAuthenticationFilter{
@Override
protected boolean onLoginSuccess(
AuthenticationToken token,
Subject subject,
ServletRequest request,
ServletResponse response) throws Exception{
final var httpRequest = (HttpRequest) request;
httpRequest.changeSessionId(); //セッションID変更する
return super.onLoginSuccess(token,subject,request,response);
}
}
继承 Shiro 的 Form 身份验证过滤器,并重写 onLoginSuccess 方法,在其中插入 changeSessionId()。
6.3.2 设置shiro.ini的配置
[main]
myauthc = my.filter.MyFormAuthFilter
myauthc.loginUrl = /login
myauthc.successUrl = /
myauthc.usernameParam = username
myauthc.passwordParam = password
myauthc.rememberMeParam = rememberMe
# (中略)
[urls]
/ = myauthc
/login = myauthc
/success = myauthc
/adminonly = myauthc,roles[admin]
/logout = logout
只需定义一个名为myauthc的自定义过滤器,然后将其用作代替Shiro提供的authc过滤器。
7. 最后
Spring Securityと比較しようかなと思ったが力尽きた。Springを使うなら、Spring Security使えばいい。
Apache Shiroは比較的使いやすいかと思うが、情報量は少ない印象。
shiro.iniに設定が集約されているので、見通しは良いし、テスト時は平文で認証できるのは楽。