一站式WebAPI與認證授權服務

波多爾斯基發表於2020-04-20

保護WEBAPI有哪些方法?

微軟官方文件推薦了好幾個:

  • Azure Active Directory
  • Azure Active Directory B2C (Azure AD B2C)]
  • IdentityServer4

前面兩個看著就覺得搞不太明白,第三個倒是非常常見,相關的文章也很多。不過這個東西是獨立部署的,太重了,如果我就想寫一個簡單一點的API,把認證給包括的,是不是有好辦法?

準備

假設你的WEBAPI使用JWT TOKEN來儲存你的認證資訊,並且通過JWT TOKEN進行保護。那麼我們可以設計一個整合有認證授權的WEBAPI服務,一站式解決問題,程式碼簡單且方便自行修改。

要點:

  1. 使用類似[Authorize]的授權,需要基於token中role這個Claim來實現。
  2. 密碼的儲存需要進行特別設計。
  3. 使用者物件返回需要避免password和passwordhash的傳遞。

專案特點:

  1. RESTful設計(正常來說api的資源應該是複數userinfos,但是info應該就是不可數的,不糾結了。)
  2. 整合Swagger
  3. ASP.NET Core 3.1
  4. nullable設計
  5. EF Core
  6. 使用者許可權控制
  7. 密碼安全儲存
  8. Token實現與API整合
  9. 簡單易於理解

使用者實體類

所有認證之類的工作都在API這邊實現,因此我們需要一個userinfo類來進行處理。

[DataContract]
[Table("userinfo")]
public class UserInfo
{
    [DataMember]
    [Key]
    public string UserId { get; set; } = default!;
    //傳輸的過程中會用到密碼,但是這個密碼不應該被存入資料庫中。
    [NotMapped]
    [DataMember]
    public string? Password { get; set; }
    //傳輸的過程中不會用到密碼雜湊值,但是雜湊值需要存入資料庫中。
    [IgnoreDataMember]
    public string? PasswordHash { get; set; }
    [DataMember]
    public string? Role { get; set; }

    public static string GetRole(string? role)
    {
        if (string.IsNullOrWhiteSpace(role)) return "User";
        return role.ToLower() switch
        {
            "administrator" => "Administrator",
            "supervisor" => "Supervisor",
            _ => "User"
        };
    }
}
  • 使用json進行序列化,[DataContract]不是必須的,我一般是不喜歡寫這個東西,不寫的話,那麼所有的public屬性和欄位都會被序列化;如果標記了[DataContract],那麼只有標記有[DataMember]的會被序列化,使用[IgnoreDataMember]可以阻止序列化。
  • 使用了EF Core用來持久化,標記[NotMapped]指示屬性不被對映到資料庫中,一般來說,資料庫不應該直接儲存密碼。

令牌發放

具體實現TokenController如下。

[AllowAnonymous]
[HttpPost]
public ActionResult Post(UserInfo login)
{
    ActionResult response = BadRequest("登入失敗,請檢查使用者名稱和密碼");
    var user = AuthenticateUser(login);

    if (user != null)
    {
        var tokenString = GenerateJSONWebToken(user);
        response = Ok(new { access_token = tokenString, role = user.Role });
    }

    return response;
}

private string GenerateJSONWebToken(UserInfo userInfo)
{
    var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
    var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

    var claims = new[] {
        new Claim(JwtRegisteredClaimNames.Sub, userInfo.UserName),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim(ClaimTypes.Role, userInfo.Role),
    };

    var token = new JwtSecurityToken(null,
        null,
        claims,
        expires: DateTime.Now.AddMinutes(120),
        signingCredentials: credentials);

    return new JwtSecurityTokenHandler().WriteToken(token);
}

private UserInfo? AuthenticateUser(UserInfo login)
{
    UserInfo? user = null;
    if (string.IsNullOrWhiteSpace(login.Password)) return user;

    using (var context = new ManageDataContext())
    {
        var result = context.UserInfos.Where(w => w.UserName.ToLower() == login.UserName.ToLower()).FirstOrDefault();
        if (result != null)
            if (PasswordStorage.VerifyPassword(login.Password, result.PasswordHash!)) user = result;
    }

    return user;
}

上面的類標誌有AllowAnonymous,表示這個類是可以匿名訪問的,使用者先請求post請求token,然後再攜帶token訪問其他API。

上面用到一個PasswordStorage的庫,這個庫使用了加鹽雜湊的形式儲存了密碼,實踐上比較可靠。值得一提的是它的VerifyPassword()函式,使用的比較演算法很巧妙,我貼在了文末,推薦大家閱讀。

受保護的API

被保護的使用者管理API如下,只貼了一小部分:

[EnableCors("AllowAll")]
[Route("api/[controller]")]
//只有角色為Admin可以訪問
[Authorize(Roles = "Admin")]
//如果需要增加種子資料,可以註釋上面這行,取消註釋下面這一行
//[AllowAnonymous]
[ApiController]
public class UserInfoController : ControllerBase
{
    private readonly ManageDataContext _context;
    public UserInfoController(ManageDataContext context)
    {
        _context = context;
    }

    /// <summary>
    /// 有參GET請求
    /// </summary>
    /// <param name="id">使用者編號id</param>
    /// <returns></returns>
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(UserInfo), Status200OK)]
    [ProducesResponseType(typeof(string), Status404NotFound)]
    public async Task<ActionResult> Get(string id)
    {
        var res = await _context.UserInfos.FindAsync(id);
        if (res != null) return Ok(res);
        else return NotFound("Cannot find key.");
    }
}

啟動配置

Startup.cs注意一下順序的問題。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    //實際測試,這個UseCors如果在UseAuthentication和UseAuthorization的後面,可能會導致vue.js訪問問題。
    app.UseCors("AllowAll");

    app.UseAuthentication();
    app.UseAuthorization();
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
    //使用AddNewtonsoftJson為了避免json的嚴格檢查。
    services.AddControllers().AddNewtonsoftJson();
    services.AddDbContext<ManageDataContext>();
    //後面還有不貼了
}

在ConfigureServices裡面,呼叫了AddNewtonsoftJson()。之所以沒有使用到預設的System.Text.Json,是因為它對客戶端上傳的資訊要求太嚴格,如果是integer型別的值,上傳使用了string就不能正確識別物件,而Newtonsoft.Json沒有這個問題。

也可以修改System.Text.Json的預設行為,但是總是沒有那麼方便了。

呼叫方法

請求令牌

POST請求,api/token,設定header:Content-Type為application/json。body內容如下:

{
  "userName": "admin",
  "password": "123"
}

呼叫即可返回access_token與role。

呼叫被保護的API

需要設定header:

  • Authorization值為Bearer [獲取到的token]
  • Content-Type為application/json
    然後就可以自由呼叫自己有權訪問的API了。

總結

零零散散寫了這麼些,直接貼上程式碼,專案是基於asp.net core 3.1與swagger的,本專案也可以作為一些小型專案的模板。

需要新建使用者的話,可以註釋掉[Authorize]或者我已經準備了一個使用者admin,密碼是123。
如果需要在windows上進行服務部署,可以參考我之前寫的TopShelf的文章

Github專案地址,歡迎Fork或者Star。

展望

  1. token重新整理與吊銷。
  2. 註冊與手機/Email驗證。

參考資料

  1. 密碼雜湊指南
  2. 加鹽雜湊指南
  3. password-hashing

相關文章