Java Authentication and Authorization Service (JAAS) 提供可插拔的認證授權框架,但傳統用法需修改 java.security、java.policy 等 VM 級配置,部署極不靈活。本文實戰示範如何在 Struts Web 應用中,透過程式化方式初始化 JAAS,使用自訂 LoginModule 與 XML 政策檔,實現零 VM 配置修改的聲明式授權。
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Login.jsp │────▶│ LoginAction │────▶│ JAAS LoginContext │
│ (表單) │ │ (Struts Action) │ │ (認證協調器) │
└─────────────┘ └──────────────────┘ └──────────┬──────────┘
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CallbackHandler │ │ LoginModule │ │ XMLPolicy │
│ (收集憑證) │ │ (驗證邏輯) │ │ (授權政策) │
└─────────────┘ └─────────────┘ └─────────────┘
<%@ taglib uri="/tags/struts-logic" prefix="logic" %>
<logic:redirect forward="index"/>
<%@ taglib uri="/tags/struts-html" prefix="html" %>
<html:form action="/LoginAction.do">
<p>User ID: <html:text property="userID" value="tyrone"/></p>
<p>Password: <html:password property="password" value="password"/></p>
<html:submit/>
</html:form>
<struts-config>
<form-beans>
<form-bean name="LoginForm" type="com.nova.colimas.web.form.LoginForm"/>
</form-beans>
<global-forwards>
<forward name="index" path="/index.do"/>
</global-forwards>
<action-mappings>
<action path="/index"
type="com.nova.colimas.web.action.StartupServlet"
forward="/pages/index.jsp"/>
<action path="/LoginAction"
type="com.nova.colimas.web.action.LoginAction"
name="LoginForm"
scope="request"
validate="true"
input="/pages/indexcon.jsp">
<forward name="success" path="/pages/index.jsp"/>
<forward name="failure" path="/pages/index.jsp"/>
</action>
</action-mappings>
</struts-config>
package com.nova.colimas.web.action;
import org.apache.struts.action.*;
public class StartupServlet extends Action {
public ActionForward execute(ActionMapping mapping, ActionForm form,
HttpServletRequest request, HttpServletResponse response)
throws Exception {
initJAAS();
return mapping.findForward("success");
}
private void initJAAS() {
// 設定 JAAS 登入配置檔路徑(而非修改 java.security)
System.setProperty("java.security.auth.login.config",
JAASConstants.AUTH_SECURITY_LOGINFILE);
// 若使用自訂 Policy,亦可在此設定:
// System.setProperty("java.security.policy", JAASConstants.AUTH_SECURITY_POLICYXMLFILE);
}
}
package com.nova.colimas.security.auth;
public interface JAASConstants {
// 使用相對路徑或從 ServletContext 取得絕對路徑,避免硬編碼
String AUTH_SECURITY_POLICYXMLFILE = "WEB-INF/security-policy.xml";
String AUTH_SECURITY_LOGINFILE = "WEB-INF/login.config";
String AUTH_SECURITY_MODULENAME = "ColimasLogin";
}
ColimasLogin {
com.nova.colimas.security.auth.ColimasLoginModule required debug=true;
};
package com.nova.colimas.security.auth;
import javax.security.auth.*;
import javax.security.auth.callback.*;
import javax.security.auth.login.*;
import javax.security.auth.spi.LoginModule;
import java.security.Principal;
import java.util.*;
public class ColimasLoginModule implements LoginModule {
private Subject subject;
private CallbackHandler callbackHandler;
private boolean debug = false;
private boolean succeeded = false;
private boolean commitSucceeded = false;
private String username;
private char[] password;
@Override
public void initialize(Subject subject, CallbackHandler callbackHandler,
Map<String, ?> sharedState, Map<String, ?> options) {
this.subject = subject;
this.callbackHandler = callbackHandler;
this.debug = "true".equalsIgnoreCase((String) options.get("debug"));
}
@Override
public boolean login() throws LoginException {
if (callbackHandler == null) {
throw new LoginException("CallbackHandler 不可為 null");
}
Callback[] callbacks = new Callback[2];
callbacks[0] = new NameCallback("userid: ");
callbacks[1] = new PasswordCallback("password: ", false);
try {
callbackHandler.handle(callbacks);
username = ((NameCallback) callbacks[0]).getName();
char[] tmpPwd = ((PasswordCallback) callbacks[1]).getPassword();
if (tmpPwd == null) tmpPwd = new char[0];
password = Arrays.copyOf(tmpPwd, tmpPwd.length);
((PasswordCallback) callbacks[1]).clearPassword();
} catch (IOException | UnsupportedCallbackException e) {
throw new LoginException("憑證收集失敗: " + e.getMessage());
}
if (debug) {
System.out.println("ColimasLoginModule: 認證用戶 [" + username + "]");
}
if (isValidUser(username, password)) {
succeeded = true;
return true;
} else {
succeeded = false;
username = null;
password = null;
throw new FailedLoginException("用戶名或密碼錯誤");
}
}
@Override
public boolean commit() throws LoginException {
if (!succeeded) return false;
// 建立 Principal 並加入 Subject
subject.getPrincipals().add(new PrincipalUser(username));
subject.getPrincipals().addAll(getUserRoles(username));
username = null;
password = null;
commitSucceeded = true;
return true;
}
@Override
public boolean abort() throws LoginException {
if (!succeeded) return false;
if (succeeded && !commitSucceeded) {
succeeded = false;
username = null;
password = null;
} else {
logout();
}
return true;
}
@Override
public boolean logout() throws LoginException {
subject.getPrincipals().clear();
succeeded = false;
commitSucceeded = false;
return true;
}
// --- 業務邏輯方法 ---
private boolean isValidUser(String uid, char[] passwd) {
// 實際專案應連接資料庫/LDAP/SSO 驗證
String validUser = "tyrone1979";
String validPwd = "197913";
return validUser.equals(uid) && validPwd.equals(new String(passwd));
}
private Collection<Principal> getUserRoles(String username) {
List<Principal> roles = new ArrayList<>();
// 從 DB/快取查詢角色
roles.add(new PrincipalUser("ROLE_ADMIN"));
roles.add(new PrincipalUser("ROLE_USER"));
return roles;
}
}
package com.nova.colimas.security.auth;
import java.security.Principal;
public class PrincipalUser implements Principal {
private final String name;
public PrincipalUser(String name) { this.name = name; }
@Override public String getName() { return name; }
@Override public boolean equals(Object o) {
return o instanceof PrincipalUser && name.equals(((PrincipalUser)o).name);
}
@Override public int hashCode() { return name.hashCode(); }
}
package com.nova.colimas.security.auth;
import javax.security.auth.callback.*;
import java.io.IOException;
public class LoginCallbackHandler implements CallbackHandler {
private final String name;
private final String password;
public LoginCallbackHandler(String name, String password) {
this.name = name;
this.password = password;
}
@Override
public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
for (Callback cb : callbacks) {
if (cb instanceof NameCallback) {
((NameCallback) cb).setName(name);
} else if (cb instanceof PasswordCallback) {
((PasswordCallback) cb).setPassword(password.toCharArray());
} else {
throw new UnsupportedCallbackException(cb, "不支援的 Callback 類型");
}
}
}
}
package com.nova.colimas.web.action;
import javax.security.auth.login.*;
import org.apache.struts.action.*;
public class LoginAction extends Action {
public ActionForward execute(ActionMapping mapping, ActionForm form,
HttpServletRequest request, HttpServletResponse response)
throws Exception {
LoginForm loginForm = (LoginForm) form;
try {
LoginContext lc = new LoginContext(
JAASConstants.AUTH_SECURITY_MODULENAME,
new LoginCallbackHandler(loginForm.getUserID(), loginForm.getPassword())
);
lc.login(); // 觸發 LoginModule.login() -> commit()
// 認證成功,Subject 已包含 Principals
// 可將 Subject 存入 Session 供後續授權使用
request.getSession().setAttribute("javax.security.auth.subject", lc.getSubject());
return mapping.findForward("success");
} catch (FailedLoginException fle) {
// 登入失敗
return mapping.findForward("failure");
} catch (LoginException le) {
// 系統錯誤
log.error("JAAS 認證異常", le);
return mapping.findForward("failure");
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<policy xmlns="https://java.sun.com/xml/ns/security/policy">
<!-- 授予特定 Principal 權限 -->
<grant principal="com.nova.colimas.security.auth.PrincipalUser \"ROLE_ADMIN\"">
<permission name="java.security.AllPermission"/>
</grant>
<grant principal="com.nova.colimas.security.auth.PrincipalUser \"ROLE_USER\"">
<permission name="com.nova.colimas.permission.BusinessPermission" actions="read,write"/>
</grant>
<!-- 程式碼基礎授權 -->
<grant codeBase="file:/WEB-INF/classes/-">
<permission name="java.util.PropertyPermission" name="*" actions="read"/>
</grant>
</policy>
| 挑戰 | 解決方案 |
|---|---|
| 避免修改 java.security、java.policy | 啟動時用 System.setProperty() 程式化指定配置檔路徑 |
| Web 容器類別載入器隔離 | 配置檔放在 WEB-INF,用相對路徑或 ServletContext.getRealPath() 解析 |
| JAAS Subject 與 Web Session 整合 | 認證成功後將 Subject 存入 Session;後續請求從 Session 取得 Subject 做授權檢查 |
| 角色/權限動態管理 | LoginModule.commit() 階段從 DB 載入角色建立 Principal;XMLPolicy 定義角色權限映射 |
| 除錯困難 | login.config 設定 debug=true;啟動 JVM 參數 -Djava.security.debug=login,policy |
新專案建議考慮:
JAAS 適用於需深度整合 Java 安全架構、既有 JAAS 基礎設施的舊系統擴展。
透過 System.setProperty() 程式化初始化、自訂 LoginModule 實現業務認證邏輯、CallbackHandler 橋接 Struts 表單、XMLPolicy 聲明式授權,成功在 Struts 應用中落地 JAAS 且零 VM 配置修改。此模式可移植至任何 Servlet 容器,為舊系統安全升級提供可行路徑。