珠聯璧合 | ServiceComb 集成 Shiro 實踐

Shiro簡介

Apache Shiro是一款功能強大、易用的輕量級開源Java安全框架,它主要提供認證、鑑權、加密和會話管理等功能。Spring Security多是業界用的最普遍的安全框架,可是Spring Security和Spring耦合的過重,脫離了Spring框架就使用不了,因此一個輕量級的安全框架有時也是一個很是不錯的選擇。html

Shiro主要經過安全API來提供四個方面使用:java

  • 認證 Authentication –提供用戶身份,能夠理解爲登陸驗證。
  • 受權 Authorization –訪問控制,也就是一般所講ACL(Access Control List)的RBAC(Role Base Access Control)或者ABAC(Attribute Base Access Control)。
  • 加密 Cryptography –加密、保護數據,確保數據安全。
  • 會話管理 Session Management –登陸後的會話管理,Shiro有獨立的會話管理機制,能夠是J2EE的會話,也能夠是普通Java應用的。

Shiro有幾個關鍵的核心概念:Subject,SecurityManager和Realms,咱們簡單的介紹下這幾個概念的含義:git

Subject
權限責任主體,主要是讓系統識別要管理的對象,好比通常系統的用戶,這個也不必定是人,也能夠是一臺設備,Subject有登陸、註銷、權限檢測等操做。全部的Subject都會綁定到SecurityManager上面,全部Subject的交互都會委託給SecurityManager。github

SecurityManager
安全管理器,全部和安全相關的操做都會與SecurityManager打交道,它管理着全部的Subject,它就是Shiro的架構核心web

Realm
領域,Shiro從Realm中獲取安全數據。Realm扮演者Shiro和應用之間的橋樑,好比用戶、角色列表。應用能夠自定義實現不一樣的Realm,Shiro也提供了幾個開箱即用的Realm,好比SimpleAccountRealm、IniRealm、JdbcRealm和DefaultLdapRealm、JndiRealm。經過這些簡單的Realm咱們能夠很簡單的上手Shiro,基本上全部定製化的擴展點都在實現自定義的Realm。spring

既然Shiro能夠提供如此全面、簡單易用的安全權限功能,那麼ServiceComb是否是也能夠很是方便的來進行集成呢?數據庫

答案固然是能夠了。apache

簡單集成

ServiceComb集成Shiro,可使用兩種方案,一種是集成Vertx-shiro,使用這種方法前提是使用Rest over Vertx的Transport方式,另一種就是使用ServiceComb的handler或者HttpServerFilter擴展點機制。json

第一種方式優勢是可使用異步的方式,徹底使用vertx的擴展機制,跟ServiceComb關聯不大,只須要擴展實現一個org.apache.servicecomb.transport.rest.vertx.VertxHttpDispatcher,在init方法中把認證邏輯加到要過濾的URL上。tomcat

一、  在POM中引入vertx-shiro依賴

<dependency>
    <groupId>io.vertx</groupId>
    <artifactId>vertx-auth-shiro</artifactId>
    <version>3.6.3</version>
</dependency>

二、  增長vertx-shiro的用戶、角色配置文件test-auth.properties

user.root = rootPassword,administrator
user.jsmith = jsmithPassword,manager,engineer,employee
user.abrown = abrownPassword,qa,employee
user.djones = djonesPassword,qa,contractor
user.test = testPassword,qa,contractor

role.administrator = *
role.manager = "user:read,write", file:execute:/usr/local/emailManagers.sh
role.engineer = "file:read,execute:/usr/local/tomcat/bin/startup.sh"
role.employee = application:use:wiki
role.qa = "server:view,start,shutdown,restart:someQaServer", server:view:someProductionServer
role.contractor = application:use:timesheet

三、  擴展實現VertxHttpDispatcher

package com.service.servicecombshiro;

import org.apache.servicecomb.foundation.vertx.VertxUtils;
import org.apache.servicecomb.transport.rest.vertx.VertxRestDispatcher;

import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.AuthProvider;
import io.vertx.ext.auth.User;
import io.vertx.ext.auth.shiro.ShiroAuth;
import io.vertx.ext.auth.shiro.ShiroAuthOptions;
import io.vertx.ext.auth.shiro.ShiroAuthRealmType;
import io.vertx.ext.web.Router;

public class AuthVertxHttpDispatcher extends VertxRestDispatcher {

  @Override
  public boolean enabled() {
    return true;
  }

  @Override
  public int getOrder() {
    return 0;
  }

  @Override
  public void init(Router router) {
    JsonObject config = new JsonObject().put("properties_path", "classpath:test-auth.properties");
    Vertx vertx = VertxUtils.getVertxMap().get("transport");
    AuthProvider authProvider = ShiroAuth
        .create(vertx, new ShiroAuthOptions().setType(ShiroAuthRealmType.PROPERTIES).setConfig(config));

    router.route().handler(rc -> {
      JsonObject authInfo = new JsonObject().put("username", "test").put("password", "testPassword");
      authProvider.authenticate(authInfo, res -> {
        if (res.failed()) {
          // Failed!
          rc.response().setStatusCode(401).end("No right!");
          return;
        }
        User user = res.result();
        System.out.println(user.principal());
        rc.next();
      });
    });
  }
}

第二種方式就是使用擴展點的機制,示例中使用HttpServerFilter擴展點機制,全部的REST請求都會走到HttpServerFilter邏輯。具體實現以下:

一、  引入shiro的依賴

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.4.1</version>
</dependency>

二、  定義shiro的用戶信息文件src\main\resources\shiro.ini文件

[users]
admin=123456
user1=Test123456

三、  使用SPI機制實現一個HttpServerFilter來作身份認證,這個簡單的示例咱們使用Http   Basic Auth的認證方式來實現基本的身份認證。首先要初始化一個SecurityManager,並注入一個Realm,而後在afterReceiveRequest方法中獲取身份信息,而且對身份信息作校驗。(因爲Shiro當前不少實現都是使用了線程上下文來傳遞SecurityManager,因此本實例只能使用同步編碼的方式)

package com.service.servicecombshiro.auth;

import org.apache.servicecomb.common.rest.filter.HttpServerFilter;
import org.apache.servicecomb.core.Invocation;
import org.apache.servicecomb.foundation.vertx.http.HttpServletRequestEx;
import org.apache.servicecomb.swagger.invocation.Response;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.realm.text.IniRealm;
import org.apache.shiro.subject.Subject;

public class HttpAuthFilter implements HttpServerFilter {

  private org.apache.shiro.mgt.SecurityManager securityManager;

  public HttpAuthFilter() {
    Realm realm = new IniRealm("classpath:shiro.ini");  //使用ini的配置方法來初始化Realm
    this.securityManager = new DefaultSecurityManager(realm);        //初始化SecurityManager
  }

  @Override
  public int getOrder() {
    return -10000;  // 確保這個Filter在通常的filter以前先執行
  }

  @Override
  public Response afterReceiveRequest(Invocation invocation, HttpServletRequestEx httpServletRequestEx) {
    SecurityUtils.setSecurityManager(securityManager);  // 由於用到了線程上下文,只支持同步編碼方式
    Subject user = SecurityUtils.getSubject();
    String userInfo = httpServletRequestEx.getHeader("Authorization");
    if (userInfo == null || userInfo.isEmpty()) {
      return Response.create(401, "Unauthorized",
          "WWW-Authenticate: Basic realm=protected_docs");
    }
    if (userInfo.length() < 5 || !userInfo.startsWith("Basic")) {
      return Response.create(401, "Unauthorized",
          "Header is wrong!");
    }
    String authInfo = userInfo.substring(5).trim();
    String[] authInfos = Base64.decodeToString(authInfo).split(":");
    if (authInfos.length != 2) {
      return Response.create(401, "Unauthorized",
          "Header is wrong!");
    }
    UsernamePasswordToken token = new UsernamePasswordToken(authInfos[0], authInfos[1]); // 獲取到請求的用戶名和密碼
    String path = httpServletRequestEx.getPathInfo();
    if (path.startsWith("/auth")) { // 只對特定的資源檢測
      try {
        user.login(token);  // 登陸不報異常表示成功了
      } catch (AuthenticationException e) {
        System.out.println("Has no right!");  // 異常表示身份認證失敗
        return Response.create(401, "Unauthorized", e.getMessage());
      }
    }
    return null;
  }
}

四、  發送請求進行驗證

curl -X GET 'http://127.0.0.1:8080/auth/helloworld?name=test' -H 'authorization: Basic YWRtaW46MTIzNDU2'

分佈式集成

微服務化的系統中,應用通常都是無狀態的,因此服務器端通常不會實現傳統的J2EE容器的會話機制,而是使用外置會話、Oath2協議,也可使用無會話方案,每次請求客戶端都帶上身份信息,服務端都對客戶端的身份進行識別,這種方案典型實現就是JWT。

一、  引入JWT和Shiro依賴

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.4.1</version>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.8.2</version>
</dependency>

二、  定義shiro的用戶配置文件src\main\resources\shiro.ini

[users]
admin=123456
user1=Test123456

三、  實現一個JWTUtils,主要用來作JWT   Token的簽名和校驗

package com.service.servicecombshiro.auth;

import java.util.Date;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;

public class JWTUtils {
  private static final Logger LOGGER = LoggerFactory.getLogger(JWTUtils.class);

  private static final int TOKEN_VALID_TIME = 5 * 60 * 1000;

  public static boolean verify(String username, String secret, String token) {
    try {
      Algorithm algorithm = Algorithm.HMAC256(secret);
      JWTVerifier verifier = JWT.require(algorithm)
          .withClaim("username", username)
          .build();
      DecodedJWT decodedJWT = verifier.verify(token);
      System.out.println(decodedJWT.getExpiresAt());
      return true;
    } catch (JWTVerificationException exception) {
      return false;
    }
  }

  public static String sign(String username, String secret) {
    try {
      Algorithm algorithm = Algorithm.HMAC256(secret);
      String token = JWT.create().withClaim("username", username)
          .withExpiresAt(new Date(System.currentTimeMillis() + TOKEN_VALID_TIME))
          .sign(algorithm);
      return token;
    } catch (JWTCreationException exception) {
      return null;
    }
  }

  public static String decodeToken(String token) {

    try {
      DecodedJWT jwt = JWT.decode(token);
      return jwt.getClaim("username").asString();
    } catch (JWTDecodeException e) {
      LOGGER.error("token is error", e);
      return null;
    }
  }
}

四、  實現一個JWTSubjectFactory,用來生成Subject,JWT認證不須要會話信息,須要設置不建立會話。

package com.service.servicecombshiro.auth;

import org.apache.shiro.mgt.DefaultSubjectFactory;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.SubjectContext;

public class JWTSubjectFactory extends DefaultSubjectFactory {
  @Override
  public Subject createSubject(SubjectContext context) {
    context.setSessionCreationEnabled(false);  // 不建立會話
    return super.createSubject(context);
  }
}

五、  建立一個JWTToken,保存JWT請求的token信息。

package com.service.servicecombshiro.auth;

import org.apache.shiro.authc.AuthenticationToken;

public class JWTToken implements AuthenticationToken {
  private String token;

  public JWTToken(String token) {
    this.token = token;
  }
  @Override
  public Object getPrincipal() {
    return token;
  }

  @Override
  public Object getCredentials() {
    return token;
  }
}

六、  實現一個JWTRealm,直接繼承IniRealm,這樣就能夠直接使用配置文件來配置用戶信息了,很是簡單。主要的就是要實現JWT的token解碼和認證。

package com.service.servicecombshiro.auth;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.SimpleAccount;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.text.IniRealm;
import org.apache.shiro.subject.PrincipalCollection;

public class JWTRealm extends IniRealm {

  public JWTRealm(String resourcePath) {
    super(resourcePath);
  }

  @Override
  public boolean supports(AuthenticationToken token) {
    return token != null && token instanceof JWTToken;
  }

  @Override
  protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    String username = JWTUtils.decodeToken(principals.toString());
    USERS_LOCK.readLock().lock();
    try {
      return this.users.get(username);
    } finally {
      USERS_LOCK.readLock().unlock();
    }
  }

  @Override
  protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    JWTToken jwtToken = (JWTToken) token;
    String username = JWTUtils.decodeToken(jwtToken.getCredentials().toString()); //解token,獲取用戶名信息
    SimpleAccount account = getUser(username);
    if (account != null) {
      if (account.isLocked()) {
        throw new LockedAccountException("Account [" + account + "] is locked.");
      }
      if (account.isCredentialsExpired()) {
        String msg = "The credentials for account [" + account + "] are expired";
        throw new ExpiredCredentialsException(msg);
      }
    }
    // token校驗,根據用戶、密碼和token,驗證token是否有效
    if (!JWTUtils.verify(username, account.getCredentials().toString(), jwtToken.getCredentials().toString())) {
      throw new AuthenticationException("the token is error, please renew one!");
    }
    // 校驗成功,返回認證完的身份信息
    SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(username,
        jwtToken.getCredentials(), getName());
    return simpleAuthenticationInfo;
  }

  public boolean canLogin(String username, String password) {
    SimpleAccount account = getUser(username);
    if (account == null) {
      return false;
    }
    if (account.getCredentials().toString().equals(password)) {
      return true;
    }
    return false;
  }
}

七、  最後就是在HTTPServerFilter裏面對請求作身份認證,由於是無狀態的,因此不須要生成會話。

package com.service.servicecombshiro.auth;

import org.apache.servicecomb.common.rest.filter.HttpServerFilter;
import org.apache.servicecomb.core.Invocation;
import org.apache.servicecomb.foundation.vertx.http.HttpServletRequestEx;
import org.apache.servicecomb.swagger.invocation.Response;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.DefaultSessionManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;

public class HttpAuthFilter implements HttpServerFilter {

  private DefaultSecurityManager securityManager;
  private JWTRealm realm;

  public HttpAuthFilter() {
    realm = new JWTRealm("classpath:shiro.ini");  //使用ini的配置方法來初始化Realm
    this.securityManager = new DefaultSecurityManager(realm);        //初始化SecurityManager
    this.securityManager.setSubjectFactory(new JWTSubjectFactory());
    DefaultSessionManager sm = new DefaultSessionManager();
    // 關閉會話校驗任務
    sm.setSessionValidationSchedulerEnabled(false);
    // 關閉會話存儲,不然會報異常
    ((DefaultSessionStorageEvaluator) ((DefaultSubjectDAO) this.securityManager.getSubjectDAO())
        .getSessionStorageEvaluator()).setSessionStorageEnabled(false);
    this.securityManager.setSessionManager(sm);
  }

  @Override
  public int getOrder() {
    return -10000;  // 確保這個Filter在通常的filter以前先執行
  }

  @Override
  public Response afterReceiveRequest(Invocation invocation, HttpServletRequestEx httpServletRequestEx) {
    SecurityUtils.setSecurityManager(securityManager);  // 由於用到了線程上下文,只支持同步編碼方式
    String path = httpServletRequestEx.getPathInfo();
    String userInfo = httpServletRequestEx.getHeader("Authorization");
    if (userInfo == null || userInfo.isEmpty()) {
      return tryLogin(httpServletRequestEx, path);
    }
    JWTToken token = new JWTToken(userInfo);

    if (path.startsWith("/auth")) { // 只對特定的資源檢測
      try {
        Subject user = SecurityUtils.getSubject();
        user.login(token);  // 登陸不報異常表示成功了
      } catch (AuthenticationException e) {
        System.out.println("Has no right!");  // 異常表示身份認證失敗
        return Response.create(401, "Unauthorized", e.getMessage());
      }
    }
    return null;
  }

  private Response tryLogin(HttpServletRequestEx httpServletRequestEx, String path) {
    if (path.equals("/login/login")) {
      // 這裏只是簡單的獲取用戶密碼,使用form表單的方式來提交
      String username = httpServletRequestEx.getParameter("username");
      String secret = httpServletRequestEx.getParameter("password");
      boolean login = realm.canLogin(username, secret);
      if (!login) {
        return Response.create(401, "Unauthorized",
            "User/Password is not right!");
      }
      String token = JWTUtils.sign(username, secret);
      return Response.createSuccess(token);
    }
    return Response.create(401, "Unauthorized",
        "JWT Token is missing, please login first!");
  }
}

查看下效果,首先請求登陸,生成一個JWT Token

再使用token請求下正常接口

若是不帶上token或者錯誤token以及token失效等時,返回401未受權

受權

上面已經實現了身份認證,有時候還須要對資源進行細粒度控制,好比有些方法只能是管理員才能調用。Shiro提供了三種受權方式:

 編碼的方式,使用硬編碼的方式檢查用戶是否有角色或者權限,這種一般用於基於配置文件或者複雜的應用。好比角色權限都配置在配置文件或者數據庫裏面,須要修改後動態生效,咱們可使用自編碼方式。
註解的方式,經過使用@RequiresPermissions/@RequiresRoles,這種方式通常都是經過AOP切面來實現的。

Subject currentUser = SecurityUtils.getSubject();
if (currentUser.hasRole("administrator")) {
//有權限
}
else {
//無權限
}

JSP標籤,如今基本上廢棄了。

ServiceComb的HttpServerFilter能夠直接獲取到調用方法的Method對象,因此在HttpServerFilter裏面能夠直接使用註解的方式來進行權限角色認證,若是是遺留應用改造先前用的是註解的方式,這樣就能夠直接兼容,不須要再從新設計。一、  定義shiro的用戶角色配置文件src\main\resources\shiro.ini,配置文件users表示用戶,好比admin=123456,   administrator, viewer表示admin用戶,密碼是123456,具備administrator,   viewer兩個角色,詳細的shiro配置能夠參考官網https://shiro.apache.org/configuration.html

[users]
admin=123456, administrator, viewer
user1=Test123456, viewer

[roles]
administrator = *
viewer = *:get

二、  在要控制權限的方法上打上註解。

package com.service.servicecombshiro.controller;

import javax.ws.rs.core.MediaType;

import org.apache.servicecomb.provider.rest.common.RestSchema;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

@RestSchema(schemaId = "auth")
@RequestMapping(path = "/auth", produces = MediaType.APPLICATION_JSON)
public class ServicecombshiroImpl {

  @Autowired
  private ServicecombshiroDelegate userServicecombshiroDelegate;


  @RequestMapping(value = "/helloworld",
      produces = {"application/json"},
      method = RequestMethod.GET)
  @RequiresRoles(value = {"viewer"})
  public String helloworld(@RequestParam(value = "name", required = true) String name) {
    return userServicecombshiroDelegate.helloworld(name);
  }

  @RequestMapping(value = "/helloworld/admin",
      produces = {"application/json"},
      method = RequestMethod.POST)
  @RequiresRoles("administrator")
  public String admin(@RequestParam(value = "name", required = true) String name) {

    return "admin " + userServicecombshiroDelegate.helloworld(name);
  }
}


三、  在HttpAuthFilter裏面加上角色權限校驗邏輯,這裏只是簡單的實現,詳細的實現須要覆蓋全部的shiro的註解。

SwaggerProducerOperation swaggerProducerOperation = invocation.getOperationMeta().getExtData(Const.PRODUCER_OPERATION);
      RequiresRoles requiresRoles = swaggerProducerOperation.getProducerMethod().getAnnotation(RequiresRoles.class);
      if (requiresRoles != null) {
        String[] roles = requiresRoles.value();
        try {
          user.checkRoles(roles);
        } catch (AuthorizationException e) {
          System.out.println("Has no required roles!");  // 異常表示權限認證失敗
          return Response.create(401, "Unauthorized", e.getMessage());
        }
      }

查看下效果,須要管理員的接口,使用admin的JWTToken來訪問,正常返回:

使用普通用戶的JWTToken來訪問管理員的接口,返回沒有權限:

使用普通用戶的JWTToken來訪問查詢接口,正常返回:

總結

Apache Shiro是一款功能強大的安全框架,ServiceComb集成使用相對來講也比較簡單,經過這個簡單的實踐,能讓ServiceComb用戶知道怎樣集成Shiro和大概的實現原理,也但願後續做爲一個子項目,直接支持Shiro集成,方便用戶使用。

項目託管地址:https://github.com/servicestage-demo/servicecomb-shiro-samples

關注公號:微服務蜂巢  更多微服務乾貨等你get!

相關文章
相關標籤/搜索