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


原文:http://blog.csdn.net/wlwlwlwl015/article/details/48518003

 

前言

 

 

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

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

 

 

身份認證與加密

 

 

如果簡單了解過shiro身份認證的一些基本概念,都應該明白shiro的身份認證的流程,大致是這樣的:當我們調用subject.login(token)的時候,首先這次身份認證會委托給Security Manager,而Security Manager又會委托給Authenticator,接着Authenticator會把傳過來的token再交給我們自己注入的Realm進行數據匹配從而完成整個認證。如果不太了解這個流程建議再仔細讀一下官方提供的Authentication說明文檔:

http://shiro.apache.org/authentication.html

 

接下來通過代碼來看看,理論往往沒有說服力,首先看一下項目結構(具體可在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官方的介紹:

 

http://shiro.apache.org/static/current/apidocs/org/apache/shiro/authc/AuthenticationException.html

 

根據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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM