1. 程式人生 > >Shiro+SpringMVC 實現更安全的登入(加密匹配&登入失敗超次數鎖定帳號)

Shiro+SpringMVC 實現更安全的登入(加密匹配&登入失敗超次數鎖定帳號)

前言

初學shiro,shiro提供了一系列安全相關的解決方案,根據官方的介紹,shiro提供了“身份認證”、“授權”、“加密”和“Session管理”這四個主要的核心功能,如下圖所示:


本篇blog主要用到了Authentication(身份認證)和Cryptography(加密),並通過這兩個核心模組來演示shiro如何幫助我們構建更安全的web project中的登入模組,實現了安全的密碼匹配和登入失敗超指定次數鎖定賬戶這兩個主要功能,下面一起來體驗一下。

身份認證與加密

如果簡單瞭解過shiro身份認證的一些基本概念,都應該明白shiro的身份認證的流程,大致是這樣的:當我們呼叫subject.login(token)的時候,首先這次身份認證會委託給

Security Manager,而Security Manager又會委託給Authenticator,接著Authenticator會把傳過來的token再交給我們自己注入的Realm進行資料匹配從而完成整個認證。如果不太瞭解這個流程建議再仔細讀一下官方提供的Authentication說明文件:

接下來通過程式碼來看看,理論往往沒有說服力,首先看一下專案結構(具體可在blog尾部下載原始碼參考)


專案通過Maven的分模組管理按層劃分,通過最常用的spring+springmvc+mybatis來結合shiro進行web最簡單的登入功能的實現,首先是登入頁面:


我們輸入使用者名稱和密碼點選submit則跳到UserController執行登入的業務邏輯,接下來看看UserController的程式碼:

package com.firstelite.cq.controller;

import java.text.SimpleDateFormat;
import java.util.Date;

import javax.servlet.http.HttpServletRequest;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.ExcessiveAttemptsException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping(value = "user")
public class UserController extends BaseController {

	@RequestMapping(value = "/LoginPage")
	public String loginPage() {
		String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
				.format(new Date());
		System.out.println(now + "to LoginPage!!!");
		return "login";
	}

	@RequestMapping(value = "/login")
	public String login(HttpServletRequest request, String username,
			String password) {
		System.out.println("username:" + username + "----" + "password:"
				+ password);
		Subject subject = SecurityUtils.getSubject();
		UsernamePasswordToken token = new UsernamePasswordToken(username,
				password);
		String error = null;
		try {
			subject.login(token);
		} catch (UnknownAccountException e) {
			error = "使用者名稱/密碼錯誤";
		} catch (IncorrectCredentialsException e) {
			error = "使用者名稱/密碼錯誤";
		} catch (ExcessiveAttemptsException e) {
			// TODO: handle exception
			error = "登入失敗多次,賬戶鎖定10分鐘";
		} catch (AuthenticationException e) {
			// 其他錯誤,比如鎖定,如果想單獨處理請單獨catch處理
			error = "其他錯誤:" + e.getMessage();
		}
		if (error != null) {// 出錯了,返回登入頁面
			request.setAttribute("error", error);
			return "failure";
		} else {// 登入成功
			return "success";
		}

	}

}

很簡單,上面的程式碼在shiro官方的10min-Tutorial就有介紹,這是shiro進行身份驗證時最基本的程式碼骨架,只不過我們集成了Spring之後就不用自己去例項化IniSecurityManagerFactory和SecurityManager了,shiro根據身份驗證的結果不同會丟擲各種各樣的異常類,如上的幾種異常是我們最常用的,如果還想了解更多相關的異常可以訪問shiro官方的介紹:

根據shiro的認證流程,最終Authenticator會把login傳入的引數token交給Realm進行驗證,Realm往往也是我們自己注入的,我們在debug模式下不難發現,在subject.login(token)打上斷點,F6之後會跳到我們Realm類中doGetAuthenticationInfo(AuthenticationToken token)這個回撥方法,從而也驗證了認證流程確實沒問題。下面貼出Realm中的程式碼:

package com.firstelite.cq.realm;

import javax.annotation.Resource;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;

import com.firstelite.cq.model.User;
import com.firstelite.cq.service.UserService;

public class UserRealm extends AuthorizingRealm {

	@Resource
	private UserService userService;

	@Override
	protected AuthorizationInfo doGetAuthorizationInfo(
			PrincipalCollection principals) {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	protected AuthenticationInfo doGetAuthenticationInfo(
			AuthenticationToken token) throws AuthenticationException {
		// TODO Auto-generated method stub
		String username = (String) token.getPrincipal();
		// 呼叫userService查詢是否有此使用者
		User user = userService.findUserByUsername(username);
		if (user == null) {
			// 丟擲 帳號找不到異常
			throw new UnknownAccountException();
		}
		// 判斷帳號是否鎖定
		if (Boolean.TRUE.equals(user.getLocked())) {
			// 丟擲 帳號鎖定異常
			throw new LockedAccountException();
		}

		// 交給AuthenticatingRealm使用CredentialsMatcher進行密碼匹配
		SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
				user.getUsername(), // 使用者名稱
				user.getPassword(), // 密碼
				ByteSource.Util.bytes(user.getCredentialsSalt()),// salt=username+salt
				getName() // realm name
		);
		return authenticationInfo;
	}

	@Override
	public void clearCachedAuthorizationInfo(PrincipalCollection principals) {
		super.clearCachedAuthorizationInfo(principals);
	}

	@Override
	public void clearCachedAuthenticationInfo(PrincipalCollection principals) {
		super.clearCachedAuthenticationInfo(principals);
	}

	@Override
	public void clearCache(PrincipalCollection principals) {
		super.clearCache(principals);
	}

	public void clearAllCachedAuthorizationInfo() {
		getAuthorizationCache().clear();
	}

	public void clearAllCachedAuthenticationInfo() {
		getAuthenticationCache().clear();
	}

	public void clearAllCache() {
		clearAllCachedAuthenticationInfo();
		clearAllCachedAuthorizationInfo();
	}

}
關於Realm我們一般都會繼承AuthorizingRealm去實現我們自己的Realm類,雖然從名字看這個Realm是用於授權的,而我們此處需要用到的是身份認證,但實際上AuthorizingRealm也繼承了AuthenticatingRealm,我們在原始碼中就可以看到:


在shiro中用Principals抽象了“身份”的概念,這裡指的是我們的username,用Credentials抽象了“證明”的概念,這裡指的是我們的password。我們在debug的時候可以發現token的資料已經正常傳過來了:




取到principals之後,我們這時應該呼叫我們自己的service進行查詢,首先查一下資料庫是否有這個使用者名稱所對應的使用者,我這裡用的是Mybatis(具體可在blog尾部下載原始碼參考):


OK這裡我們不會丟擲UnknownAccountException這個異常了,繼續按F6往下走,可以發現我判斷了賬號是否鎖定,這個是為系統預留一個可以鎖定賬戶的功能,而本demo也提供了登入失敗次數上限鎖定賬戶的功能,後面再說,先看一下User這個實體Bean:

package com.firstelite.cq.model;

import java.io.Serializable;

public class User implements Serializable {

	private static final long serialVersionUID = 1L;
	private Long id;
	private String username;
	private String password;
	private String salt;

	private Boolean locked = Boolean.FALSE;

	public User() {
	}

	public User(String username, String password) {
		this.username = username;
		this.password = password;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getUsername() {
		return username;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public String getSalt() {
		return salt;
	}

	public void setSalt(String salt) {
		this.salt = salt;
	}

	public String getCredentialsSalt() {
		return username + salt;
	}

	public Boolean getLocked() {
		return locked;
	}

	public void setLocked(Boolean locked) {
		this.locked = locked;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;

		User user = (User) o;

		if (id != null ? !id.equals(user.id) : user.id != null)
			return false;

		return true;
	}

	@Override
	public int hashCode() {
		return id != null ? id.hashCode() : 0;
	}

	@Override
	public String toString() {
		return "User{" + "id=" + id + ", username='" + username + '\''
				+ ", password='" + password + '\'' + ", salt='" + salt + '\''
				+ ", locked=" + locked + '}';
	}
}

可以看到除了username和password還定義了一個salt,這個salt就是加密時會用到的“鹽”,起一個混淆的作用使我們的密碼更難破譯,例如:密碼本是123,又用任意的一個字串如“abcefg”做為鹽,比如通過md5進行雜湊時雜湊的物件就是“123abcefg”了,往往我們用一些系統知道的資料作為鹽,例如使用者名稱,關於雜湊為什麼建議加鹽,shiro api中的HashedCredentialsMatcher有這樣一段話:

Because simple hashing is usually not good enough for secure applications, this class also supports 'salting' and multiple hash iterations. Please read this excellentHashing Java articleto learn about salting and multiple iterations and why you might want to use them. (Note of sections 5 "Why add salt?" and 6 "Hardening against the attacker's attack"). We should also note here that all of Shiro's Hash implementations (for example, Md5Hash, Sha1Hash, etc) support salting and multiple hash iterations via overloaded constructors.
繼續回到我們的UserRealm往下除錯,

如果身份驗證成功,依然是返回一個AuthenticationInfo實現,可不同的是多指定了一個引數,


設定這個鹽的目的就是為了讓HashedCredentialsMatcher去識別它!關於什麼是HashedCredentialsMatcher,這裡就引出了shiro提供的用於加密密碼和驗證密碼服務的CredentialsMatcher介面,而HashedCredentialsMatcher正是CredentialsMatcher的一個實現類,我們在原始碼中可以看到它們的繼承關係:




瞭解了它們的繼承關係,我們現在看一下我們自己的HashedCredentialsMatcher類:

package com.firstelite.cq.util;

import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExcessiveAttemptsException;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;

import java.util.concurrent.atomic.AtomicInteger;

public class RetryLimitHashedCredentialsMatcher extends
		HashedCredentialsMatcher {

	private Cache<String, AtomicInteger> passwordRetryCache;

	public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) {
		passwordRetryCache = cacheManager.getCache("passwordRetryCache");
	}

	@Override
	public boolean doCredentialsMatch(AuthenticationToken token,
			AuthenticationInfo info) {
		String username = (String) token.getPrincipal();
		// retry count + 1
		AtomicInteger retryCount = passwordRetryCache.get(username);
		if (retryCount == null) {
			retryCount = new AtomicInteger(0);
			passwordRetryCache.put(username, retryCount);
		}
		if (retryCount.incrementAndGet() > 5) {
			// if retry count > 5 throw
			throw new ExcessiveAttemptsException();
		}

		boolean matches = super.doCredentialsMatch(token, info);
		if (matches) {
			// clear retry count
			passwordRetryCache.remove(username);
		}
		return matches;
	}
}

這裡的邏輯也不復雜,在回撥方法doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info)中進行身份認證的密碼匹配,這裡我們引入了Ehcahe用於儲存使用者登入次數,如果登入失敗retryCount變數則會一直累加,如果登入成功,那麼這個count就會從快取中移除,從而實現瞭如果登入次數超出指定的值就鎖定。我們看一下spring的快取配置和ehcache的配置:
	<!-- 快取管理器 使用Ehcache實現 -->
	<bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
		<property name="cacheManagerConfigFile" value="classpath:conf/ehcache.xml" />
	</bean>

ehcache.xml:
<?xml version="1.0" encoding="UTF-8"?>
<ehcache name="shirocache">

	<diskStore path="java.io.tmpdir" />

	<!-- 登入記錄快取 鎖定10分鐘 -->
	<cache name="passwordRetryCache" eternal="false"
		timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false"
		statistics="true">
	</cache>

	<cache name="authorizationCache" eternal="false"
		timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false"
		statistics="true">
	</cache>

	<cache name="authenticationCache" eternal="false"
		timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false"
		statistics="true">
	</cache>

	<cache name="shiro-activeSessionCache" eternal="false"
		timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false"
		statistics="true">
	</cache>

</ehcache>

可以看到在ehcache.xml中我們配置了鎖定的時間。這裡注意一下ehcache的版本,根據shiro的EhcacheManager的要求ehcache的版本必須是1.2以上,這一點我們在原始碼中也可以看到:


而且儘量不要用2.5或2.5以上的,不然可能會報這樣一個錯:

Another unnamed CacheManager already exists in the same VM. Please provide unique names for each CacheManager in the config or do one of following:
1. Use one of the CacheManager.create() static factory methods to reuse same CacheManager with same name or create one if necessary
2. Shutdown the earlier cacheManager before creating new one with same name.

我這裡用的是2.4.8版本的ehcache:

		<dependency>
			<groupId>net.sf.ehcache</groupId>
			<artifactId>ehcache-core</artifactId>
			<version>2.4.8</version>
		</dependency>


下面再回到重點,密碼是如何匹配的?我們在我們自定義的HashedCredentialsMatcher應該可以看到這樣一個方法:

boolean matches = super.doCredentialsMatch(token, info);

顯而易見,是通過這個方法進行密碼驗證的,如果成功,則清除ehcache中儲存的記錄登入失敗次數的count。我們可以看到這個方法的兩個引數,token和info,它們是回撥方法:

boolean doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info) 由UserRealm傳過來的引數,所以至於如何驗證密碼,其實還是由UserRealm返回的SimpleAuthenticationInfo決定的。HashedCredentialsMatcher允許我們指定自己的演算法和鹽,比如:我們採取加密的方法是(3次md5迭代,使用者名稱+隨機數當作鹽),通過shiro提供的通用雜湊來實現:

	public static void main(String[] args) {
		String algorithmName = "md5";
		String username = "wang";
		String password = "111111";
		String salt1 = username;
		String salt2 = new SecureRandomNumberGenerator().nextBytes().toHex();
		int hashIterations = 3;
		SimpleHash hash = new SimpleHash(algorithmName, password,
				salt1 + salt2, hashIterations);
		String encodedPassword = hash.toHex();
		System.out.println(encodedPassword);
		System.out.println(salt2);
	}

我們輸出密碼和隨機數,儲存到資料庫中模擬已經註冊好的使用者資料:


這樣我們在UserRealm中呼叫UserService的時候就可以查詢出密碼和鹽,最後通過SimpleAuthenticationInfo將它們組裝起來即可,上面也提到了HashedCredentialsMatcher會自動識別這個鹽。還有不要忘記演算法要一致,即加密和匹配時的演算法,如果我們採取上述main方法中的加密方式,那麼我們需要給自定義的HashedCredentialsMatcher注入如下屬性(具體可在blog尾部下載原始碼參考)

	<!-- 憑證匹配器 -->
	<bean id="credentialsMatcher"
		class="com.firstelite.cq.util.RetryLimitHashedCredentialsMatcher">
		<constructor-arg ref="cacheManager" />
		<property name="hashAlgorithmName" value="md5" />
		<property name="hashIterations" value="3" />
		<property name="storedCredentialsHexEncoded" value="true" />
	</bean>

可以看到hashAlogorithmName指定了雜湊演算法的名稱,hashTterations指定了加密的迭代次數,而最後一個屬性表示是否儲存雜湊後的密碼為16進位制,需要和生成密碼時的一樣,預設是base64。由於我們加密的時候是通過“使用者名稱+隨機數”的形式指定的鹽,那麼在組裝SimpleAuthenticationInfo也應該以此格式去組裝鹽的引數:
	public String getCredentialsSalt() {
		return username + salt;
	}
		SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
				user.getUsername(), // 使用者名稱
				user.getPassword(), // 密碼
				ByteSource.Util.bytes(user.getCredentialsSalt()),// salt=username+salt
				getName() // realm name
		);

最後測試一下login:


可以看到登入成功,下面再看一下輸入錯誤密碼的情況和超過輸錯5次的情況:


可以看到當我們輸錯5次,那麼第6次的時候就會提示賬戶鎖定異常,並且繼續登入的話依舊是這個異常。

總結

本篇blog主要介紹了shiro關於“使用者認證”的相關內容,參考了開濤的系列shiro教程(http://jinnianshilongnian.iteye.com/blog/2018398),但總覺的開濤講的很深奧作為菜鳥有點看不懂,於是自己從新總結了一遍,一點一點的debug去理解shiro的認證流程,從原始碼中也看到了一些靈感,算是對shiro有了一個入門性的認識,關於授權和Session管理等相關內容後續用到會繼續學習總結,希望能給和我一樣的新手朋友提供一些幫助吧,如果有不正確的地方也歡迎批評指正,最後再次感謝開濤、yangc、鴻洋等等這些樂於開源和分享的人。

原始碼下載地址:http://download.csdn.net/detail/wlwlwlwl015/9115397