Authentication and authorization
认证是一个获取身份证明的过程,一般都是验证用户名和密码是否匹配。如果身份验证通过,然后授权就会决定哪些数据是这身份可以访问的。
有很多种方式可以将认证和授权组件添加到基于ASP.NET MVC的Xamarin.Forms应用程序中,如ASP.NETCore Identity,Microsoft、Google、Facebook等的认证API,或者一些其它的认证中间件。eShopOnContainers手机app则使用的是基于IdentityServer4的容器化认证微服务。app可以从IdentityServer请求一个安全的令牌(token),或者用来验证是否是一个有效的用户,也可以验证用户是否拥有访问某项资源的权限。IdentityServer如果验证到传递过来的某个token有问题,会提示让用户重新登录。但是IdentityServer并不提供可以让用户进行登录的页面,也不直接连接数据库。所以为了解决这个问题,eShopOnContainers手机app添加了一个ASP.NET Core Identity项目。
认证
当应用程序需要知道当前用户的身份时,认证就会被发起。ASP.NET Core识别用户的首选机制就是ASP.NET Core Identity membership系统,它可以将用户的信息存储在数据库中。
在ASP.NET MVC应用程序中,一般都使用cookie来记录身份信息。手机app通常无法使用这套机制,但使用bearer token则可以解决这个问题,bearer token可以可容易的从手机app发出的web request的请求头中获取或写入。
使用IdentityServer4签发bearer token
IdentityServer4是ASP.NET Core中一个整合了OpenID Connect和OAuth2.0的开源框架。它可以被用到多种需要认证和授权的场景中,包括给ASP.NET Core Identity用户签发安全的token。
备注:OpenID Connect和OAuth2.0非常相似,但是职责不同。
OpenID Connect是OAuth2.0协议上面的一个认证层。OAuth2.0是一种协议,它允许应用程序从一个安全的令牌服务上获取access token,并用这个access token与API交互数据。因为认证和授权可以被集中化,所以它降低了客户端程序和API的复杂度。OpenID Connect和OAuth2.0的结合解决了认证和API访问这两个基本问题,而IdentityServer4就是这些协议的一个实现。
在客户端直连微服务的这种应用程序中,如eShopOnContainers,会有一个专门的认证微服务来扮演Security Token Service(STS)的角色去验证一个用户是否合法。
- 使用IdentityServer对用户进行身份验证是通过手机app请求身份令牌(identity token)来实现的,该token代表了认证过程的结果。这个token中至少包括了用户的标识符,用户进行认证的时间和方式。当然也可以包括其它的身份信息。
- 使用IdentityServer来访问某一资源是通过请求访问令牌(access token)来实现的。客户端请求一个access token然后再用这个token去获取特定的API资源。access token包括了客户端信息和用户信息。API拿到这个token后去授权它们可以访问数据。
备注:客户端在请求token之前一定要在IdentityServer中注册。
在web application中添加IdentityServer
可以使用nuget在ASP.NET Core web application中安装IdentityServer。一旦安装成功之后,需要在Starup.cs的Configure方法中进行如下的配置:
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) {
...
app.UseIdentity();
...
}
将IdentityServer添加到了处理http请求的管道中。因为在管道中http请求的处理顺序是很重要的,所以要把IdentityServer添加到展示任何UI之前。
配置IdentityServer
需要在Startup.cs的ConfigureServices方法中,进行如下的配置:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddIdentityServer(x => x.IssuerUri = "null")
.AddSigningCredential(Certificate.Get())
.AddAspNetIdentity<ApplicationUser>()
.AddConfigurationStore(builder =>builder.UseSqlServer(connectionString, options =>options.MigrationsAssembly(migrationsAssembly)))
.AddOperationalStore(builder =>builder.UseSqlServer(connectionString, options =>options.MigrationsAssembly(migrationsAssembly)))
.Services.AddTransient<IProfileService, ProfileService>();
}
在调用services.AddIdentityServer之后,又配置了:
- 签名所用的凭据
- 可能会被请求到的API和身份资源
- 会连接到IdentityServer的客户端
- ASP.NET Core Identity
动态加载IdentityServer4的配置
在eShopOnContainers中,这些配置是代码写死了,而在生产环境中这些配置可以从配置文件或者数据库中读取。详见IdentityServer4的文档。
配置API resources
在配置API resources时,AddInMemoryApiRresources
方法需要一个IEnumeralbe集合。如下:
public static IEnumerable<ApiResource> GetApis()
{
return new List<ApiResource>
{
new ApiResource("orders", "Orders Service"),
new ApiResource("basket", "Basket Service")
};
}
这里指定了IdentityServer需要保护orders和basket这两个API。因此当请求这两个API时,IdentityServer就会索要并验证access token。
配置Identity resources
同理,在配置时,AddInMemoryIdentityResources
方法需要一个IEnumerabl集合。Identity resources是一种类似ID,name,email之类的数据。每一个Identity resource都有一个唯一的name和一些claim,这些数据到时候都会包括在identity token中。以下是eShopOnContainer中的示例:
public static IEnumerable<IdentityResource> GetResources()
{
return new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile()
};
}
OpenId Connect指定了一些标准的identity resources。最低的要求是需要支持向用户发放唯一ID,这个可以通过IdentityResources.OpenId()
方法实现。
备注:IdentityResources支持OpenID Connect定义的所有scope,比如openid、email、profile、telephone、address等。而且还支持自定义的Identity resources。
配置客户端
只要向IdentityServer请求token的都叫客户端,包括web client、desktop client、mobile client等。以下这几项对于客户端来说是必须要配置的:
- 客户端的一个唯一ID
- 授权类型(grant type)
- 需要将Identity token或者access token发送到哪里的一个url
- 客户端能访问的资源(即scopes)
当配置的时候,需要给AddInMemoryClients
方法传入一个IEnumerable集合。如下所示:
public static IEnumerable<Client> GetClients(Dictionary<string,string> clientsUrl)
{
return new List<Client>
{
...
new Client
{
ClientId = "xamarin",
ClientName = "eShop Xamarin OpenId Client",
AllowedGrantTypes = GrantTypes.Hybrid,
ClientSecrets =
{
new Secret("secret".Sha256())
},
RedirectUris = { clientsUrl["Xamarin"] },
RequireConsent = false,
RequirePkce = true,
PostLogoutRedirectUris = { $"{clientsUrl["Xamarin"]}/Account/Redirecting" },
AllowedCorsOrigins = { "http://eshopxamarin" },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
"orders",
"basket"
},
AllowOfflineAccess = true,
AllowAccessTokensViaBrowser = true
},
...
};
}
配置的意思如下:
- ClientId:客户端的唯一id
- ClientName:客户端的名称,随便写,一般用来展示和记录日志。
- AllowedGrantTypes:指定客户端与IdentityServer的交互类型,请见IdentityServer文档。
- ClientSecrets:指定当客户端获取token时,所使用的客户端密码凭据。
- RedirectUris:指定允许返回token和授权码(authorization code)的Uris。
- RequireConsent:是否显示consent页面。
- RequirePkce:指定客户端使用authorization code时,是否必须发送证明秘钥。
- PostLogoutRedirectUris:退出登录后,导向到哪个Uri。
- AllowedCorsOrigins:指定客户端的来源,以便IdentityServer允许跨来源(跨域)访问。
- AllowedScopes:指定客户端能访问哪些资源,默认客户端任何资源都不访问。
- AllowOfflineAccess:指定客户端是否可以请求refresh token。
配置认证流程
客户端和IdentityServer间的认证流程可以在客户端指定grant type来配置。OpenID Connect和OAuth2.0指定了一些认证流程,包括:
- Implicit:专为基于浏览器的应用程序进行了优化,仅用户身份验证、请求access token和authentication token。因为所有的token都通过浏览器转发,所以一些高级功能如refresh token是不支持的。
- Authorization code:提供了在后台通道(back channel)接受token的功能,而不是从浏览器的前台通道(front channel)接受,同样支持客户端认证。
- Hybrid:是上述两种方式的结合。浏览器的前台通道用来转发Identity token和authorization code。认证成功之后,后台通道用来接受access token和refresh token。
提示:建议使用hybrid流程。相对于浏览器的前台通道来说可以减轻一些攻击,而且也是本地应用程序接受access token和refresh token的建议流程。更多信息请参考IdentityServer4文档。
开始身份认证
对于IdentityServer来说发放的token就代表了用户,所以用户必须在IdentityServer进行注册,但是IdentityServer不提供进行注册的页面。于是乎就用ASP.NET Core Identity来解决这个问题。
在eShopOnContainer手机app中,与IdentityServer的通信是由IdentityServie
这个类进行的。它实现了IIdentityService接口,并实现了三个方法:CreateAuthorizationRequest
,CreateLogoutRequest
,GetTokenAsync
。
登录
当用户点击登录是,将会调用LoginViewModel的如下代码:
private async Task SignInAsync()
{
...
LoginUrl = _identityService.CreateAuthorizationRequest();
IsLogin = true;
...
}
这里调用了_identityService的CreateAuthorizationRequest方法,具体实现是:
public string CreateAuthorizationRequest()
{
// Create URI to authorization endpoint
var authorizeRequest = new AuthorizeRequest(GlobalSetting.Instance.IdentityEndpoint);
// Dictionary with values for the authorize request
var dic = new Dictionary<string, string>();
dic.Add("client_id", GlobalSetting.Instance.ClientId);
dic.Add("client_secret", GlobalSetting.Instance.ClientSecret);
dic.Add("response_type", "code id_token");
dic.Add("scope", "openid profile basket orders locations marketing offline_access");
dic.Add("redirect_uri", GlobalSetting.Instance.IdentityCallback);
dic.Add("nonce", Guid.NewGuid().ToString("N"));
dic.Add("code_challenge", CreateCodeChallenge());
dic.Add("code_challenge_method", "S256");
// Add CSRF token to protect against cross-site request forgery attacks.
var currentCSRFToken = Guid.NewGuid().ToString("N");
dic.Add("state", currentCSRFToken);
var authorizeUri = authorizeRequest.Create(dic);
return authorizeUri;
}
一旦登录成功后,WebView会被导航到返回的URI上(上面的authorizeUri),然后会触发LoginViewModel的NavigateAsync方法:
private async Task NavigateAsync(string url)
{
...
var authResponse = new AuthorizeResponse(url);
if (!string.IsNullOrWhiteSpace(authResponse.Code))
{
var userToken = await _identityService.GetTokenAsync(authResponse.Code);
string accessToken = userToken.AccessToken;
if (!string.IsNullOrWhiteSpace(accessToken))
{
Settings.AuthAccessToken = accessToken;
Settings.AuthIdToken = authResponse.IdentityToken;
await NavigationService.NavigateToAsync<MainViewModel>();
await NavigationService.RemoveLastFromBackStackAsync();
}
}
...
}
这个方法从返回的uri中取出了access token和identity token并存到本地。
退出
当点击退出时会调用LoginViewModel中的Logout方法:
private void Logout()
{
var authIdToken = Settings.AuthIdToken;
var logoutRequest = _identityService.CreateLogoutRequest(authIdToken);
if (!string.IsNullOrEmpty(logoutRequest))
{
// Logout
LoginUrl = logoutRequest;
}
...
}
这段代码调用了_identityService的CreateLogoutRequest方法,并传入了identity token。方法的实现如下:
public string CreateLogoutRequest(string token)
{
...
return string.Format("{0}?id_token_hint={1}&post_logout_redirect_uri={2}", GlobalSetting.Instance.LogoutEndpoint,token,GlobalSetting.Instance.LogoutCallback);
}
返回了一个logout之后的重定向uri给LoginUrl。然后会触发LoginViewModel的NavigateAsync方法:
private async Task NavigateAsync(string url)
{
...
Settings.AuthAccessToken = string.Empty;
Settings.AuthIdToken = string.Empty;
IsLogin = false;
LoginUrl = _identityService.CreateAuthorizationRequest();
...
}
这里清除了本地的access token和identity token,重新赋值了LoginUrl以便重新登录。
授权
认证通过之后,ASP.NET Core web API一般也需要授权后才能访问。
可以在controller上或者action上添加[Authorize]
注解,表明这个controller或者action需要授权后才能访问。如:
[Authorize]
public class BasketController : Controller
{
...
}
如果一个未授权的用户来访问这个BasketController,那么系统返回一个401错误。
备注:可以通过给Authorize注解添加参数的方式来限制哪些用户可以访问这个controller
access token包括了客户端信息和用户信息,API可以用这些信息来判断是否允许访问。详见前面讲过的《配置API resources》章节。
配置IdentityServer进行授权
如果要IdentiServer进行授权管理,则需要把它的中间件配置到http请求管道中,在Startup.cs文件中添加如下的虚方法:
protected virtual void ConfigureAuth(IApplicationBuilder app)
{
var identityUrl = Configuration.GetValue<string>("IdentityUrl"); app.UseIdentityServerAuthentication(new IdentityServerAuthenticationOptions
{
Authority = identityUrl.ToString(),
ScopeName = "basket",
RequireHttpsMetadata = false
});
}
这个方法确保了API只能被正确的access token所访问。它会验证来访的token确保是来自于被信任的发放者,并且验证这个token能否访问这个API。因为如果你直接访问order或者basket controller的话,会直接返回401.
注意:IdentityServer的授权中间件,必须添加到http请求管理的app.UseMVC()或app.UseMvcWithDefaultRoute()前面。
请求访问API
在访问order或者basket微服务是,必须将上面过程中获取到的access token放到请求里:
var authToken = Settings.AuthAccessToken;
Order = await _ordersService.GetOrderAsync(Convert.ToInt32(order.OrderNumber), authToken);
同样的,当向被IdentityServer保护的API发送数据时,也要带上access token:
var authToken = Settings.AuthAccessToken;
await _basketService.UpdateBasketAsync(new CustomerBasket
{
BuyerId = userInfo.UserId,
Items = BasketItems.ToList()
}, authToken);
eShopOnContainers中的RequestProvider
类专门用来发送http请求,本质是用HttpClient来实现的。将access token放到了httpClient对象的请求头里,代码如下:
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
当请求被发送到RESTful API上时,请求头里的数据就会被解压认证,确保是可信的,然后再确定这个用户能否访问这个API。