1. 程式人生 > >Shiro的身份認證(Authentication)

Shiro的身份認證(Authentication)

Apache Shiro 是一個強大且靈活的 Java 開源安全框架,擁有登入認證、授權管理、企業級會話管理和加密等功能,相比 Spring Security 來說要更加的簡單。

本文主要介紹 Shiro 的登入認證(Authentication)功能,主要從 Shiro 設計的角度去看這個登入認證的過程。

一、Shiro 總覽

首先,我們思考整個認證過程的業務邏輯:

  1. 獲取使用者輸入的使用者名稱,密碼;
  2. 從伺服器資料來源中獲取相應的使用者名稱和密碼;
  3. 判斷密碼是否匹配,決定是否登入成功。

我們現在來看看 Shiro 是如何設計這個過程的:

圖中包含三個重要的 Shiro 概念:SubjectSecurityManager

Realm。接下來,分別介紹這三者有何用:

  • Subject:表示“使用者”,表示當前執行的使用者。Subject 例項全部都繫結到了一個 SecurityManager 上,當和 Subject 互動時,它是委託給 SecurityManager 去執行的。
  • SecurityManager:Shiro 結構的心臟,協調它內部的安全元件(如登入,授權,資料來源等)。當整個應用配置好了以後,大多數時候都是直接和 Subject 的 API 打交道。
  • Realm:資料來源,也就是抽象意義上的 DAO 層。它負責和安全資料互動(比如儲存在資料庫的賬號、密碼,許可權等資訊),包括獲取和驗證。Shiro 支援多個 Realm,但是至少也要有一個。Shiro 自帶了很多開箱即用的 Reams,比如支援 LDAP、關係資料庫(JDBC)、INI 和 properties 檔案等。但是很多時候我們都需要實現自己的 Ream 去完成獲取資料和判斷的功能。

登入驗證的過程就是:Subject 執行 login 方法,傳入登入的「使用者名稱」和「密碼」,然後 SecurityManager將這個 login 操作委託給內部的登入模組,登入模組就呼叫 Realm 去獲取安全的「使用者名稱」和「密碼」,然後對比,一致則登入,不一致則登入失敗。

Shiro 詳細結構

ShiroArchitecture

二、Shiro 登入示例

程式碼來自 Shiro 官網教程。Shiro 配置 INI 檔案:

# ----------------------------------------------------------------------------
# Users and their (optional) assigned roles
# username = password, role1, role2, ..., roleN
# ----------------------------------------------------------------------------
[users]
wang=123
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

測試 main 方法:

public static void main(String[] args) {

    log.info("My First Apache Shiro Application");

    //1.從 Ini 配置檔案中獲取 SecurityManager 工廠
    Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");

    //2.獲取 SecurityManager 例項
    SecurityManager securityManager = factory.getInstance();

    //3.將 SecurityManager 例項繫結給 SecurityUtils
    SecurityUtils.setSecurityManager(securityManager);

    //4.獲取當前登入使用者
    Subject currentUser = SecurityUtils.getSubject();

    //5.判斷是否登入,如果未登入,則登入
    if (!currentUser.isAuthenticated()) {
        //6.建立使用者名稱/密碼驗證Token(Web 應用中即為前臺獲取的使用者名稱/密碼)
        UsernamePasswordToken token = new UsernamePasswordToken("wang", "123");
        try {
            //7.執行登入,如果登入未成功,則捕獲相應的異常
            currentUser.login(token);
        } catch (UnknownAccountException uae) {
            log.info("There is no user with username of " + token.getPrincipal());
        } catch (IncorrectCredentialsException ice) {
            log.info("Password for account " + token.getPrincipal() + " was incorrect!");
        } catch (LockedAccountException lae) {
            log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                    "Please contact your administrator to unlock it.");
        }
        // ... catch more exceptions here (maybe custom ones specific to your application?
        catch (AuthenticationException ae) {
            //unexpected condition?  error?
        }
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

三、登入邏輯詳解

Shiro 登入過程主要涉及到 Subject.login 方法,接下來我們將通過檢視原始碼來分析整個登入過程。

  1. 建立 AuthenticationToken 介面的例項 token,比如例子中的 UsernamePasswordToken,包含了登入的使用者名稱和密碼;
  2. 獲取當前使用者 Subject,然後呼叫 Subject.login(AuthenticationToken) 方法;
  3. Subject 將 login 代理給 SecurityManager 的 login()

3.1 建立AuthenticationToken

第一步是建立 AuthenticationToken 介面的身份 token,比如例子中的 UsernamePasswordToken

package org.apache.shiro.authc;

public interface AuthenticationToken extends Serializable {
    // 獲取“使用者名稱”
    Object getPrincipal();
    // 獲取“密碼”
    Object getCredentials();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

3.2 獲取當前使用者並執行登入

獲取的 Subject 當前使用者是我們平時打交道最多的介面,有很多方法,但是這裡我們只分析 login 方法。

package org.apache.shiro.subject;

public interface Subject {

    void login(AuthenticationToken token) throws AuthenticationException;

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

login 方法接受一個 AuthenticationToken 引數,如果登入失敗則丟擲 AuthenticationException 異常,可通過判斷異常型別來知悉具體的錯誤型別。

接下來,分析 Subject 介面的實現類 DelegatingSubject 是如何實現 login 方法的:

public void login(AuthenticationToken token) throws AuthenticationException {
    clearRunAsIdentitiesInternal();
    // 代理給SecurityManager
    Subject subject = securityManager.login(this, token);
    ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

3.3 SecurityManager 介面

前面說過,整個 Shiro 安全框架的心臟就是 SecurityManager,我們看這個介面都有哪些方法:

package org.apache.shiro.mgt;

public interface SecurityManager extends Authenticator, Authorizer, SessionManager {

    Subject login(Subject subject, AuthenticationToken authenticationToken) throws AuthenticationException;

    void logout(Subject subject);

    Subject createSubject(SubjectContext context);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

SecurityManager 包含很多內建的模組來完成功能,比如登入(Authenticator),許可權驗證(Authorizer)等。這裡我們看到 SecurityManager 介面繼承了 Authenticator 登入認證的介面:

package org.apache.shiro.authc;

public interface Authenticator {

    public AuthenticationInfo authenticate(AuthenticationToken authenticationToken)
            throws AuthenticationException;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

那麼,SecurityManager 的實現都是怎樣來實現 Authenticator 介面的呢?答案是:使用了組合。SecurityManager 都擁有一個 Authenticator 的屬性,這樣呼叫 SecurityManager.authenticate 的時候,是委託給內部的 Authenticator 屬性去執行的。

SecurityManager

3.4 SecurityManager.login 的實現

// DefaultSecurityManager.java
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
    AuthenticationInfo info;
    try {
        info = authenticate(token);
    } catch (AuthenticationException ae) {
        try {
            onFailedLogin(token, ae, subject);
        } catch (Exception e) {
            if (log.isInfoEnabled()) {
                log.info("onFailedLogin method threw an " +
                        "exception.  Logging and propagating original AuthenticationException.", e);
            }
        }
        throw ae; //propagate
    }

    Subject loggedIn = createSubject(token, info, subject);

    onSuccessfulLogin(token, info, loggedIn);

    return loggedIn;
}

// AuthenticatingSecurityManager.java
/**
 * Delegates to the wrapped {@link org.apache.shiro.authc.Authenticator Authenticator} for authentication.
 */
public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
    return this.authenticator.authenticate(token);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  1. 呼叫自己的 authenticate 方法執行登入;
  2. 在 authenticate 方法中代理給 Authenticator 介面型別的屬性去真正執行 authenticate(token) 方法。

3.5 Authenticator 登入模組

Authenticator 介面如下:

package org.apache.shiro.authc;

public interface Authenticator {

    public AuthenticationInfo authenticate(AuthenticationToken authenticationToken)
            throws AuthenticationException;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

其實現類有 AbstractAuthenticator 和 ModularRealmAuthenticator

Authenticator

下面來看看如何實現的 authenticate 方法:

// AbstractAuthenticator.java
public final AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
    AuthenticationInfo info;
    try {
        // 呼叫doAuthenticate方法
        info = doAuthenticate(token);
        if (info == null) {
            ...
        }
    } catch (Throwable t) {
        ...
    }
    ...
}

// ModularRealmAuthenticator.java
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
    assertRealmsConfigured();
    Collection<Realm> realms = getRealms();
    if (realms.size() == 1) {
        // Realm唯一時
        return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
    } else {
        return doMultiRealmAuthentication(realms, authenticationToken);
    }
}

protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
    if (!realm.supports(token)) {
        ...
    }
    // 呼叫Realm的getAuthenticationInfo方法獲取AuthenticationInfo資訊
    AuthenticationInfo info = realm.getAuthenticationInfo(token);
    if (info == null) {
        ...
    }
    return info;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

從原始碼中可以看出,最後會呼叫 Realm 的 getAuthenticationInfo(AuthenticationToken) 方法。

3.6 Realm 介面

Realm 相當於資料來源,功能是通過 AuthenticationToken 獲取資料來源中的安全資料,這個過程中可以丟擲異常,告訴 shiro 登入失敗。

package org.apache.shiro.realm;

public interface Realm {

    // 獲取 shiro 唯一的 realm 名稱
    String getName();

    // 是否支援給定的 AuthenticationToken 型別
    boolean supports(AuthenticationToken token);

    // 獲取 AuthenticationInfo
    AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

Shiro 自帶了很多開箱即用的 Realm 實現,具體的類圖如下:

Realm

3.7 總結

到此,我們把整個 Shiro 的登入認證流程分析了一遍。

  1. 建立 AuthenticationToken,然後呼叫 Subject.login 方法進行登入認證;
  2. Subject 委託給 SecurityManager
  3. SecurityManager 委託給 Authenticator 介面;
  4. Authenticator 介面呼叫 Realm 獲取登入資訊。

整個過程中,如果登入失敗,就丟擲異常,是使用異常來進行邏輯控制的。

四、登入密碼的儲存

  1. 頁面使用 Https 協議;
  2. 頁面傳送密碼時要先加密後再傳輸,最好是不可逆的加密演算法(MD5,SHA2);
  3. 後端儲存時要結合鹽(隨機數)一起加密儲存;
  4. 使用不可逆的加密演算法,而且可以加密多次;
  5. 把加密後的密碼和鹽一起儲存到資料庫;

五、學習 Shiro 原始碼感悟

  1. 從整體去思考框架的實現,帶著業務邏輯去看實現邏輯;
  2. 不要摳細節,要看抽象,學習其實現方法;
  3. 首先看官方文件,官方文件一般會從整體設計方面去說明,遇到具體的介面再去看Javadoc文件;
  4. 結合類圖等工具方便理解;