ASP.NET Core WebAPI中使用JWT Bearer認證和受權

爲何是 JWT Bearer

ASP.NET Core 在 Microsoft.AspNetCore.Authentication 下實現了一系列認證, 包含 Cookie, JwtBearer, OAuth, OpenIdConnect 等,git

  • Cookie 認證是一種比較經常使用本地認證方式, 它由瀏覽器自動保存並在發送請求時自動附加到請求頭中, 更適用於 MVC 等純網頁系統的本地認證.
  • OAuth & OpenID Connect 一般用於運程認證, 建立一個統一的認證中心, 來統一配置和處理對於其餘資源和服務的用戶認證及受權.
  • JwtBearer 認證中, 客戶端一般將 JWT(一種Token) 經過 HTTP 的 Authorization header 發送給服務端, 服務端進行驗證. 能夠方便的用於 WebAPI 框架下的本地認證.
    固然, 也能夠徹底本身實現一個WebAPI下基於Token的本地認證, 好比自定義Token的格式, 本身寫頒發和驗證Token的代碼等. 這樣的話通用性並很差, 並且也須要花費更多精力來封裝代碼以及處理細節.

什麼是 JWT

JWT (JSON Web Token) 是一種基於JSON的、用於在網絡上聲明某種主張的令牌(token)。
做爲一個開放的標準(RFC 7519),定義了一種簡潔的、自包含的方法,從而使通訊雙方實現以JSON對象的形式安全的傳遞信息。github

JWT一般由三部分組成: 頭信息(header), 消息體(payload)和簽名(signature)。
頭信息指定了該JWT使用的簽名算法:web

header = {"alg": "HS256", "typ": "JWT"}

消息體包含了JWT的意圖:算法

payload = {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}

未簽名的令牌由base64url編碼的頭信息和消息體拼接而成(使用"."分隔),簽名則經過私有的key計算而成:數據庫

key = "secretkey" 
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload)  
signature = HMAC-SHA256(key, unsignedToken)

最後在尾部拼接上base64url編碼的簽名(一樣使用"."分隔)就是JWT了:json

token = encodeBase64(header) + '.' + encodeBase64(payload) + '.' + encodeBase64(signature)

JWT經常被用做保護服務端的資源,客戶端一般將JWT經過HTTP的Authorization header發送給服務端,服務端使用本身保存的key計算、驗證簽名以判斷該JWT是否可信。後端

Authorization: Bearer <token>

JWT 的優缺點

相比於傳統的 cookie-session 認證機制,優勢有:api

  1. 更適用分佈式和水平擴展
    在cookie-session方案中,cookie內僅包含一個session標識符,而諸如用戶信息、受權列表等都保存在服務端的session中。若是把session中的認證信息都保存在JWT中,在服務端就沒有session存在的必要了。當服務端水平擴展的時候,就不用處理session複製(session replication)/ session黏連(sticky session)或是引入外部session存儲了。瀏覽器

  2. 適用於多客戶端(特別是移動端)的先後端解決方案
    移動端使用的每每不是網頁技術,使用Cookie驗證並非一個好主意,由於你得和Cookie容器打交道,而使用Bearer驗證則簡單的多。

  3. 無狀態化
    JWT 是無狀態化的,更適用於 RESTful 風格的接口驗證。

它的缺點也很明顯:

  1. 更多的空間佔用
    JWT 因爲Payload裏面包含了附件信息,佔用空間每每比SESSION ID大,在HTTP傳輸中會形成性能影響。因此在設計時候須要注意不要在JWT中存儲太多的claim,以免發生巨大的,過分膨脹的請求。

  2. 沒法做廢已頒佈的令牌
    全部的認證信息都在JWT中,因爲在服務端沒有狀態,即便你知道了某個JWT被盜取了,你也沒有辦法將其做廢。在JWT過時以前(你絕對應該設置過時時間),你無能爲力。

在 WebAPI 中使用 JWT 認證

  1. 定義配置類 JwtIssuerOptions.cs

    public class JwtIssuerOptions
    {
        /// <summary>
        /// 4.1.1.  "iss" (Issuer) Claim - The "iss" (issuer) claim identifies the principal that issued the JWT.
        /// </summary>
        public string Issuer { get; set; }
    
        /// <summary>
        /// 4.1.2.  "sub" (Subject) Claim - The "sub" (subject) claim identifies the principal that is the subject of the JWT.
        /// </summary>
        public string Subject { get; set; }
    
        /// <summary>
        /// 4.1.3.  "aud" (Audience) Claim - The "aud" (audience) claim identifies the recipients that the JWT is intended for.
        /// </summary>
        public string Audience { get; set; }
    
        /// <summary>
        /// 4.1.4.  "exp" (Expiration Time) Claim - The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing.
        /// </summary>
        public DateTime Expiration => IssuedAt.Add(ValidFor);
    
        /// <summary>
        /// 4.1.5.  "nbf" (Not Before) Claim - The "nbf" (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing.
        /// </summary>
        public DateTime NotBefore => DateTime.UtcNow;
    
        /// <summary>
        /// 4.1.6.  "iat" (Issued At) Claim - The "iat" (issued at) claim identifies the time at which the JWT was issued.
        /// </summary>
        public DateTime IssuedAt => DateTime.UtcNow;
    
        /// <summary>
        /// Set the timespan the token will be valid for (default is 120 min)
        /// </summary>
        public TimeSpan ValidFor { get; set; } = TimeSpan.FromMinutes(120);
    
    
        /// <summary>
        /// "jti" (JWT ID) Claim (default ID is a GUID)
        /// </summary>
        public Func<Task<string>> JtiGenerator =>
          () => Task.FromResult(Guid.NewGuid().ToString());
    
        /// <summary>
        /// The signing key to use when generating tokens.
        /// </summary>
        public SigningCredentials SigningCredentials { get; set; }
    }
  2. 定義的幫助類 JwtFactory.cs, 主要是用於生成Token

    public interface IJwtFactory
    {
        Task<string> GenerateEncodedToken(string userName, ClaimsIdentity identity);
        ClaimsIdentity GenerateClaimsIdentity(User user);
    }
    
    public class JwtFactory : IJwtFactory
    {
        private readonly JwtIssuerOptions _jwtOptions;
    
        public JwtFactory(IOptions<JwtIssuerOptions> jwtOptions)
        {
            _jwtOptions = jwtOptions.Value;
            ThrowIfInvalidOptions(_jwtOptions);
        }
    
        public async Task<string> GenerateEncodedToken(string userName, ClaimsIdentity identity)
        {
            var claims = new List<Claim>
            {
                new Claim(JwtRegisteredClaimNames.Sub, userName),
                new Claim(JwtRegisteredClaimNames.Jti, await _jwtOptions.JtiGenerator()),
                new Claim(JwtRegisteredClaimNames.Iat, ToUnixEpochDate(_jwtOptions.IssuedAt).ToString(), ClaimValueTypes.Integer64),
                identity.FindFirst(ClaimTypes.Name),
                identity.FindFirst("id")
            };
            claims.AddRange(identity.FindAll(ClaimTypes.Role));
    
            // Create the JWT security token and encode it.
            var jwt = new JwtSecurityToken(
                issuer: _jwtOptions.Issuer,
                audience: _jwtOptions.Audience,
                claims: claims,
                notBefore: _jwtOptions.NotBefore,
                expires: _jwtOptions.Expiration,
                signingCredentials: _jwtOptions.SigningCredentials);
    
            var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);
    
            var response = new
            {
                auth_token = encodedJwt,
                expires_in = (int)_jwtOptions.ValidFor.TotalSeconds,
                token_type = "Bearer"
            };
    
            return JsonConvert.SerializeObject(response, new JsonSerializerSettings { Formatting = Formatting.Indented });
        }
    
        public ClaimsIdentity GenerateClaimsIdentity(User user)
        {
            var claimsIdentity  = new ClaimsIdentity(new GenericIdentity(user.UserName, "Token"));
            claimsIdentity.AddClaim(new Claim("id", user.Id.ToString()));
            claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));
            foreach (var role in user.Roles)
            {
                claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, role));
            }
            return claimsIdentity;
        }
    
        /// <returns>Date converted to seconds since Unix epoch (Jan 1, 1970, midnight UTC).</returns>
        private static long ToUnixEpochDate(DateTime date)
          => (long)Math.Round((date.ToUniversalTime() -
                               new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero))
                              .TotalSeconds);
    
        private static void ThrowIfInvalidOptions(JwtIssuerOptions options)
        {
            if (options == null) throw new ArgumentNullException(nameof(options));
    
            if (options.ValidFor <= TimeSpan.Zero)
            {
                throw new ArgumentException("Must be a non-zero TimeSpan.", nameof(JwtIssuerOptions.ValidFor));
            }
    
            if (options.SigningCredentials == null)
            {
                throw new ArgumentNullException(nameof(JwtIssuerOptions.SigningCredentials));
            }
    
            if (options.JtiGenerator == null)
            {
                throw new ArgumentNullException(nameof(JwtIssuerOptions.JtiGenerator));
            }
        }
    }
  3. 在 Startup.cs 裏面添加相關代碼:

    讀取配置:

    var jwtAppSettingOptions = Configuration.GetSection(nameof(JwtIssuerOptions));
    services.Configure<JwtIssuerOptions>(options =>
    {
        options.Issuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)];
        options.Audience = jwtAppSettingOptions[nameof(JwtIssuerOptions.Audience)];
        options.SigningCredentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256);
    });

    JwtBearer驗證:

    public class Startup
    {
        private const string SecretKey = "iNivDmHLpUA223sqsfhqGbMRdRj1PVkH"; // todo: get this from somewhere secure
        private readonly SymmetricSecurityKey _signingKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(SecretKey));
    
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<IJwtFactory, JwtFactory>();
    
            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    
            }).AddJwtBearer(configureOptions =>
            {
                configureOptions.ClaimsIssuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)];
                configureOptions.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = jwtAppSettingOptions[nameof(JwtIssuerOptions.Issuer)],
                    ValidateAudience = true,
                    ValidAudience = jwtAppSettingOptions[nameof(JwtIssuerOptions.Audience)],
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = _signingKey,
                    RequireExpirationTime = false,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.Zero
                };
                configureOptions.SaveToken = true;
            });
        }
    
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseAuthentication();
            app.UseMvc();
        }
    }

    Swagger相關:

    services.AddSwaggerGen(options =>
    {
        var security = new Dictionary<string, IEnumerable<string>> { { "Bearer", new string[] { } }, };
        options.AddSecurityRequirement(security);
        options.AddSecurityDefinition("Bearer", new Swashbuckle.AspNetCore.Swagger.ApiKeyScheme
        {
            Description = "Format: Bearer {auth_token}",
            Name = "Authorization",
            In = "header"
        });
    });
  4. 建立一個控制器 AuthController.cs,用來提供簽發 Token 的 API

    [Route("api/[controller]")]
    [ApiController]
    public class AuthController : ControllerBase
    {
        private readonly IJwtFactory _jwtFactory;
        private readonly JwtIssuerOptions _jwtOptions;
    
        public AuthController(IJwtFactory jwtFactory, IOptions<JwtIssuerOptions> jwtOptions)
        {
            _jwtFactory = jwtFactory;
            _jwtOptions = jwtOptions.Value;
        }
    
        /// <summary>
        /// Log in
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [HttpPost("[action]")]
        public async Task<IActionResult> Login([FromBody]LoginRequest request)
        {
            var users = TestUsers.Users.Where(r => r.UserName.Equals(request.UserName));
            if (users.Count() <= 0)
            {
                ModelState.AddModelError("login_failure", "Invalid username.");
                return BadRequest(ModelState);
            }
            var user = users.First();
            if (!request.Password.Equals(user.Password))
            {
                ModelState.AddModelError("login_failure", "Invalid password.");
                return BadRequest(ModelState);
            }
    
            var claimsIdentity = _jwtFactory.GenerateClaimsIdentity(user.UserName, user.Id.ToString());
            var token = await _jwtFactory.GenerateEncodedToken(user.UserName, claimsIdentity);
            return new OkObjectResult(token);
        }
    
        /// <summary>
        /// Get User Info
        /// </summary>
        /// <returns></returns>
        [HttpGet("[action]")]
        [Authorize]
        public IActionResult GetUserInfo()
        {
            var claimsIdentity = User.Identity as ClaimsIdentity;
            return Ok(claimsIdentity.Claims.ToList().Select(r=> new { r.Type, r.Value}));
        }
    }
  5. 爲須要保護的API添加 [Authorize] 特性

    [Route("api/[controller]")]
    [ApiController]
    [Authorize]
    public class ValuesController : ControllerBase
    {
        // GET api/values
        [HttpGet]
        public ActionResult<IEnumerable<string>> Get()
        {
            return new string[] { "value1", "value2" };
        }
    }
  6. 使用 Swagger UI 或者 PostMan 等工具測試

    獲取Token:

    curl -X POST "http://localhost:5000/api/Auth/Login" -H "accept: application/json" -H "Content-Type: application/json-patch+json" -d "{ \"userName\": \"Paul\", \"password\": \"Paul123\"}"

    返回值:

    "{\r\n  \"auth_token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQYXVsIiwianRpIjoiM2I1YzEyMzMtZTI1YS00ZWU5LWJkNjYtY2Y0NjU2YWMzM2QzIiwiaWF0IjoxNTQ0NTg5ODY5LCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiUGF1bCIsImlkIjoiZDM3ZjI3Y2UtODc4MC00NDI1LTkxMzUtYjY4OGE3NmM0YzBmIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbImFkbWluaXN0cmF0b3IiLCJhcGlfYWNjZXNzIl0sIm5iZiI6MTU0NDU4OTg2OCwiZXhwIjoxNTQ0NTk3MDY4LCJpc3MiOiJTZWN1cml0eURlbW8uQXV0aGVudGljYXRpb24uSldUIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwLyJ9.UAWLYQ5lA6xWofWIjGsPGWtAMHEtqZSfrfVaBui2mKI\",\r\n  \"expires_in\": 7200,\r\n  \"token_type\": \"Bearer\"\r\n}"

    https://jwt.io/ 上解析 Token 以下:

    {
      "sub": "Paul",
      "jti": "3b5c1233-e25a-4ee9-bd66-cf4656ac33d3",
      "iat": 1544589869,
      "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "Paul",
      "id": "d37f27ce-8780-4425-9135-b688a76c4c0f",
      "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": ["administrator","api_access"],
      "nbf": 1544589868,
      "exp": 1544597068,
      "iss": "SecurityDemo.Authentication.JWT",
      "aud": "http://localhost:5000/"
    }

    使用 Token 訪問受保護的 API

    curl -X GET "http://localhost:5000/api/Values" -H "accept: text/plain" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQYXVsIiwianRpIjoiM2I1YzEyMzMtZTI1YS00ZWU5LWJkNjYtY2Y0NjU2YWMzM2QzIiwiaWF0IjoxNTQ0NTg5ODY5LCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiUGF1bCIsImlkIjoiZDM3ZjI3Y2UtODc4MC00NDI1LTkxMzUtYjY4OGE3NmM0YzBmIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbImFkbWluaXN0cmF0b3IiLCJhcGlfYWNjZXNzIl0sIm5iZiI6MTU0NDU4OTg2OCwiZXhwIjoxNTQ0NTk3MDY4LCJpc3MiOiJTZWN1cml0eURlbW8uQXV0aGVudGljYXRpb24uSldUIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwLyJ9.UAWLYQ5lA6xWofWIjGsPGWtAMHEtqZSfrfVaBui2mKI"

刷新 Token

由於JWT在服務端是沒有狀態的, 不管用戶註銷, 修改密碼仍是Token被盜取, 你都沒法將其做廢. 因此給JWT設置有效期而且儘可能短是頗有必要的. 但咱們不可能讓用戶每次Token過時後都從新輸入一次用戶名和密碼爲了生成新的Token. 最好是有種方式在用戶無感知的狀況下完成Token刷新. 因此這裏引入了Refresh Token.

  1. 修改 JwtFactory 中的 GenerateEncodedToken 方法, 新加一個參數 refreshToken, 並在包含在 response 裏和 auth_token 一塊兒返回.

    public async Task<string> GenerateEncodedToken(string userName, string refreshToken, ClaimsIdentity identity)
    {
        var response = new
        {
            auth_token = encodedJwt,
            refresh_token = refreshToken,
            expires_in = (int)_jwtOptions.ValidFor.TotalSeconds,
            token_type = "Bearer"
        };
    
        return JsonConvert.SerializeObject(response, new JsonSerializerSettings { Formatting = Formatting.Indented });
    }
  2. 修改 AuthController 中的 Login Action, 在每次客戶端請求 JWT Token 的時候, 同時生成一個 GUID 的 refreshToken. 這個 refreshToken 須要保存在數據庫或者緩存裏. 這裏方便演示放入了 MemoryCache 裏面. 緩存的過時時間要比JWT Token的過時時間稍微長一點.

    string refreshToken = Guid.NewGuid().ToString();
    var claimsIdentity = _jwtFactory.GenerateClaimsIdentity(user);
    
    _cache.Set(refreshToken, user.UserName, TimeSpan.FromMinutes(11));
    
    var token = await _jwtFactory.GenerateEncodedToken(user.UserName, refreshToken, claimsIdentity);
    return new OkObjectResult(token);
  3. 添加一個RefreshToken的接口, 接收參數 refresh_token, 而後檢查 refresh_token 的有效性, 若是有效生成一個新的 auth_token 和 refresh_token 並返回. 同時須要刪除掉原來 refresh_token 的緩存.
    這裏只是簡單的利用緩存的過時時間和auth_token的過時時間相近從而默認 refresh_token 是有效的, 精確期間須要把對應的 auth_token過時時間一塊兒放入緩存, 在刷新Token的時候驗證這個時間.

    /// <summary>
    /// RefreshToken
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    [HttpPost("[action]")]
    public async Task<IActionResult> RefreshToken([FromBody]RefreshTokenRequest request)
    {
        string userName;
        if (!_cache.TryGetValue(request.RefreshToken, out userName))
        {
            ModelState.AddModelError("refreshtoken_failure", "Invalid refreshtoken.");
            return BadRequest(ModelState);
        }
        if (!request.UserName.Equals(userName))
        {
            ModelState.AddModelError("refreshtoken_failure", "Invalid userName.");
            return BadRequest(ModelState);
        }
    
        var user = _userService.GetUserByName(request.UserName);
        string newRefreshToken = Guid.NewGuid().ToString();
        var claimsIdentity = _jwtFactory.GenerateClaimsIdentity(user);
    
        _cache.Remove(request.RefreshToken);
        _cache.Set(newRefreshToken, user.UserName, TimeSpan.FromMinutes(11));
    
        var token = await _jwtFactory.GenerateEncodedToken(user.UserName, newRefreshToken, claimsIdentity);
        return new OkObjectResult(token);
    }
  4. 測試

    獲取Token:

    curl -X POST "http://localhost:5000/api/Auth/Login" -H "accept: application/json" -H "Content-Type: application/json-patch+json" -d "{ \"userName\": \"Paul\", \"password\": \"Paul123\"}"

    返回值:

    "{\r\n  \"auth_token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQYXVsIiwianRpIjoiNzA5Y2VkNjEtNWQ2ZS00N2RlLTg4NjctNzVjZGM0N2U0MWZiIiwiaWF0IjoxNTQ0NjgxOTA0LCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiUGF1bCIsImlkIjoiZmE3NjMxYzEtMzk0NS00MzUwLThjM2YtOWYxZDRhODU0MDFhIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbImFkbWluaXN0cmF0b3IiLCJhcGlfYWNjZXNzIl0sIm5iZiI6MTU0NDY4MTkwMywiZXhwIjoxNTQ0NjgyNTAzLCJpc3MiOiJTZWN1cml0eURlbW8uQXV0aGVudGljYXRpb24uSldUIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwLyJ9.tEJ-EuaI-BalW4lJEL8aeJzdryKfE440EC4cAVOW1PY\",\r\n  \"refresh_token\": \"3093f839-fd3c-47a3-97a9-c0324e4e6b7e\",\r\n  \"expires_in\": 600,\r\n  \"token_type\": \"Bearer\"\r\n}"

    請求RefreshToken:

    curl -X POST "http://localhost:5000/api/Auth/RefreshToken" -H "accept: application/json" -H "Content-Type: application/json-patch+json" -d "{ \"userName\": \"Paul\", \"refreshToken\": \"3093f839-fd3c-47a3-97a9-c0324e4e6b7e\"}"

    返回新的 auth_token 和 refresh_token

    "{\r\n  \"auth_token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQYXVsIiwianRpIjoiMjI2M2Y4NGEtZjlmMC00ZTM1LWI1YTUtMDdhYmI0M2UzMWQ5IiwiaWF0IjoxNTQ0NjgxOTIxLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiUGF1bCIsImlkIjoiZmE3NjMxYzEtMzk0NS00MzUwLThjM2YtOWYxZDRhODU0MDFhIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbImFkbWluaXN0cmF0b3IiLCJhcGlfYWNjZXNzIl0sIm5iZiI6MTU0NDY4MTkyMSwiZXhwIjoxNTQ0NjgyNTIxLCJpc3MiOiJTZWN1cml0eURlbW8uQXV0aGVudGljYXRpb24uSldUIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwLyJ9.A1hXNVmkqD80GqfF69LwvarpNf5QedPvKFDcB5xA4Z0\",\r\n  \"refresh_token\": \"b33de8ff-5213-4d37-be0b-7b561553e0f7\",\r\n  \"expires_in\": 600,\r\n  \"token_type\": \"Bearer\"\r\n}"

使用受權

在認證階段咱們經過用戶令牌獲取到了用戶的Claims,而受權即是對這些Claims進行驗證, 好比是否擁有某種角色,年齡是否大於18歲(若是Claims裏有年齡信息)等。

簡單受權

ASP.NET Core中使用Authorize特性受權, 使用AllowAnonymous特性跳過受權.

//全部用戶均可以Login, 但只有受權的用戶才能夠Logout.
public class AccountController : Controller
{
    [AllowAnonymous]
    public ActionResult Login()
    {
    }
    
    [Authorize]
    public ActionResult Logout()
    {
    }
}

基於固定角色的受權

適用於系統中的角色是固定的,每種角色能夠訪問的Controller和Action也是固定的情景.

//能夠指定多個角色, 以逗號分隔
[Authorize(Roles = "Administrator")]
public class AdministrationController : Controller
{
}

基於策略的受權

在ASP.NET Core中,從新設計了一種更加靈活的受權方式:基於策略的受權, 它是受權的核心.
在使用基於策略的受權時,首先要定義受權策略,而受權策略本質上就是對Claims的一系列斷言。
基於角色的受權和基於Scheme的受權,只是一種語法上的便捷,最終都會生成受權策略。

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    services.AddAuthorization(options =>
    {
        //options.AddPolicy("Administrator", policy => policy.RequireRole("administrator"));
        options.AddPolicy("Administrator", policy => policy.RequireClaim(ClaimTypes.Role, "administrator"));
        
        //options.AddPolicy("Founders", policy => policy.RequireClaim("EmployeeNumber", "1", "2", "3", "4", "5"));
    });
}
[Authorize(Policy = "Administrator")]
public ActionResult<IEnumerable<string>> GetValueByAdminPolicy()
{
    return new string[] { "GetValueByAdminPolicy" };
}

自定義策略受權

基於策略的受權中有一個很重要的概念是Requirements,每個Requirement都表明一個受權條件。
Requirement須要繼承接口IAuthorizationRequirement。
在 ASP.NET Core 中已經內置了一些經常使用的實現:

  • AssertionRequirement :使用最原始的斷言形式來聲明受權策略。
  • DenyAnonymousAuthorizationRequirement :用於表示禁止匿名用戶訪問的受權策略,並在AuthorizationOptions中將其設置爲默認策略。
  • ClaimsAuthorizationRequirement :用於表示判斷Cliams中是否包含預期的Claims的受權策略。
  • RolesAuthorizationRequirement :用於表示使用ClaimsPrincipal.IsInRole來判斷是否包含預期的Role的受權策略。
  • NameAuthorizationRequirement:用於表示使用ClaimsPrincipal.Identities.Name來判斷是否包含預期的Name的受權策略。
  • OperationAuthorizationRequirement:用於表示基於操做的受權策略。

除了OperationAuthorizationRequirement外,都有對應的快捷添加方法,好比RequireClaimRequireRoleRequireUserName等。

當內置的Requirement不能知足需求時,能夠定義本身的Requirement. 下面基於圖中所示的用戶-角色-功能權限設計來實現一個自定義的驗證策略。
用戶權限表定義

  1. 添加一個靜態類 TestUsers 用於模擬用戶數據
    這裏只是模擬, 實際使用當中確定是從數據庫取數據, 同時也應該有相似於User, Role, Function, UserRole, RoleFunction等幾張表保存這些數據.

    public static class TestUsers
    {
        public static List<User> Users = new List<User>
        {
            new User{ Id = Guid.NewGuid(), UserName = "Paul", Password = "Paul123", Roles = new List<string>{ "administrator", "api_access" }, Urls = new List<string>{ "/api/values/getadminvalue", "/api/values/getguestvalue" }},
            new User{ Id = Guid.NewGuid(), UserName = "Young", Password = "Young123", Roles = new List<string>{ "api_access" }, Urls = new List<string>{ "/api/values/getguestvalue" }},
            new User{ Id = Guid.NewGuid(), UserName = "Roy", Password = "Roy123", Roles = new List<string>{ "administrator" }, Urls = new List<string>{ "/api/values/getadminvalue" }},
        };
    }
    
    public class User
    {
        public Guid Id { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
        public List<string> Roles { get; set; }
        public List<string> Urls { get; set; }
    }
  2. 建立類 UserService 用於獲取用戶已受權的功能列表.

    public interface IUserService
    {
        List<string> GetFunctionsByUserId(Guid id);
    }
    
    public class UserService : IUserService
    {
        public List<string> GetFunctionsByUserId(Guid id)
        {
            var user = TestUsers.Users.SingleOrDefault(r => r.Id.Equals(id));
            return user?.Urls;
        }
    }
  3. 建立 PermissionRequirement

    public class PermissionRequirement : IAuthorizationRequirement
    {
    }
  4. 建立 PermissionHandler
    獲取當前的URL, 並去當前用戶已受權的URL List裏查看. 若是匹配就驗證成功.

    public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
    {
        private readonly IUserService _userService;
    
        public PermissionHandler(IUserService userService)
        {
            _userService = userService;
        }
    
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
        {
            var httpContext = (context.Resource as Microsoft.AspNetCore.Mvc.Filters.AuthorizationFilterContext).HttpContext;
    
            var isAuthenticated = httpContext.User.Identity.IsAuthenticated;
            if (isAuthenticated)
            {
                Guid userId;
                if (!Guid.TryParse(httpContext.User.Claims.SingleOrDefault(s => s.Type == "id").Value, out userId))
                {
                    return Task.CompletedTask;
                }
                var functions = _userService.GetFunctionsByUserId(userId);
                var requestUrl = httpContext.Request.Path.Value.ToLower();
                if (functions != null && functions.Count > 0 && functions.Contains(requestUrl))
                {
                    context.Succeed(requirement);
                }
            }
            return Task.CompletedTask;
        }
    }
  5. 在Startup.cs 的 ConfigureServices 裏面註冊 PermissionHandler 並添加 Policy.

    services.AddAuthorization(options =>
    {
        options.AddPolicy("Permission", policy => policy.Requirements.Add(new PermissionRequirement()));
    });
    services.AddSingleton<IAuthorizationHandler, PermissionHandler>();
  6. 添加測試代碼並測試
    注意這裏Controller, Action須要和用戶功能表裏的URL一致

    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        [HttpGet("[action]")]
        [Authorize(Policy = "Permission")]
        public ActionResult<IEnumerable<string>> GetAdminValue()
        {
            return new string[] { "use Policy = Permission" };
        }
    
        [HttpGet("[action]")]
        [Authorize(Policy = "Permission")]
        public ActionResult<IEnumerable<string>> GetGuestValue()
        {
            return new string[] { "use Policy = Permission" };
        }
    }

    使用咱們的模擬數據, 用戶 Paul 兩個Action GetAdminValue 和 GetGuestValue 均可以訪問; Young 只有權限訪問 GetGuestValue; 而 Roy 只能夠訪問 GetAdminValue.

基於資源的受權

有些時候, 受權須要依賴於要訪問的資源, 好比:只容許做者本身編輯和刪除所寫的博客.
這種場景是沒法經過Authorize特性來指定受權的, 由於受權過濾器會在MVC的模型綁定以前執行,沒法肯定所訪問的資源。此時,咱們須要使用基於資源的受權。
在基於資源的受權中, 咱們要判斷的是用戶是否具備針對該資源的某項操做, 而系統預置的OperationAuthorizationRequirement就是用於這種場景中的.

public class OperationAuthorizationRequirement : IAuthorizationRequirement
{
    public string Name { get; set; }
}
  1. 定義一些經常使用操做, 方便業務調用.

    public static class ResourceOperations
    {
        public static OperationAuthorizationRequirement Create = new OperationAuthorizationRequirement { Name = "Create" };
        public static OperationAuthorizationRequirement Read = new OperationAuthorizationRequirement { Name = "Read" };
        public static OperationAuthorizationRequirement Update = new OperationAuthorizationRequirement { Name = "Update" };
        public static OperationAuthorizationRequirement Delete = new OperationAuthorizationRequirement { Name = "Delete" };
    }
  2. 咱們是根據資源的建立者來判斷用戶是否具備操做權限,所以,定義一個資源實體的接口, 包含一個字段 Creator

    public interface IResourceWithCreator
    {
        string Creator { get; set; }
    }
  3. 定義測試數據用於模擬

    public static class TestBlogs
    {
        public static List<Blog> Blogs = new List<Blog>
        {
            new Blog{ Id = Guid.Parse("CA4A3FC9-42CA-47F4-B651-36A863023E75"), Name = "Paul_Blog_1", BlogUrl = "blogs/paul/1", Creator = "Paul" },
            new Blog{ Id = Guid.Parse("9C03EDA8-FBCD-4C33-B5C8-E4DFC40258D7"), Name = "Paul_Blog_2", BlogUrl = "blogs/paul/2", Creator = "Paul" },
            new Blog{ Id = Guid.Parse("E05E3625-1885-49A5-87D0-54F7EAF90C88"), Name = "Young_Blog_1", BlogUrl = "blogs/young/1", Creator = "Young" },
            new Blog{ Id = Guid.Parse("E97D5DF4-AE50-4258-84F8-0B3052EB2CB8"), Name = "Roy_Blog_1", BlogUrl = "blogs/roy/1", Creator = "Roy" },
        };
    }
    
    public class Blog : IResourceWithCreator
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string BlogUrl { get; set; }
    
        public string Creator { get; set; }
    }
  4. 定義 ResourceAuthorizationHandler
    容許任何人建立或查看資源, 有隻有資源的建立者才能夠修改和刪除資源.

    public class ResourceAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, IResourceWithCreator>
    {
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, OperationAuthorizationRequirement requirement, IResourceWithCreator resource)
        {
            if (requirement == ResourceOperations.Create || requirement == ResourceOperations.Read)
            {
                context.Succeed(requirement);
            }
            else
            {
                if (context.User.Identity.Name == resource.Creator)
                {
                    context.Succeed(requirement);
                }
            }
            return Task.CompletedTask;
        }
    }
  5. 在ConfigureServices裏註冊Handler.

    services.AddSingleton<IAuthorizationHandler, ResourceAuthorizationHandler>();
  6. 添加控制器並引入IAuthorizationService進行驗證

    [Authorize]
    public class BlogsController : ControllerBase
    {
        private readonly IAuthorizationService _authorizationService;
        private readonly IBlogService _blogService;
    
        public BlogsController(IAuthorizationService authorizationService, IBlogService blogService)
        {
            _authorizationService = authorizationService;
            _blogService = blogService;
        }
    
        [HttpGet("{id}", Name = "Get")]
        public async Task<ActionResult<Blog>> Get(Guid id)
        {
            var blog = _blogService.GetBlogById(id);
            if ((await _authorizationService.AuthorizeAsync(User, blog, ResourceOperations.Read)).Succeeded)
            {
                return Ok(blog);
            }
            else
            {
                return Forbid();
            }
        }
    
        [HttpPut("{id}")]
        public async Task<ActionResult> Put(Guid id, [FromBody] Blog newBlog)
        {
            var blog = _blogService.GetBlogById(id);
            if ((await _authorizationService.AuthorizeAsync(User, blog, ResourceOperations.Update)).Succeeded)
            {
                bool result = _blogService.Update(newBlog);
                return Ok(result);
            }
            else
            {
                return Forbid();
            }
        }
    }

    在實際使用當中, 能夠經過EF Core攔截或AOP來實現受權驗證與業務代碼的分離。

源代碼

github

參考

相關文章
相關標籤/搜索