最近接手新的web项目,针对登录部分做了部分研究,将整个过程记录下来。
我们的技术路线是: 前端采用Angular
,后端用.net core web api
.
前言
什么是JWT
Json web token (JWT)
, 是为了在网络应用环境间传递声明而执行的一种基于JSON
的开放标准((RFC 7519
).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO
)场景。JWT
的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token
也可直接被用于认证,也可被加密。
组成结构
JSON Web Token
由三部分组成,它们之间用圆点(.)连接。这三部分分别是:
- Header
- Payload
- Signature
因此,一个典型的JWT
看起来是这个样子的:
1
xxxxx.yyyyy.zzzzz
header
header
典型的由两部分组成:token
的类型(JWT
)和算法名称(比如:HMAC
SHA256
或者RSA
等等)。
1
2
3
4
{
'alg': "HS256",
'typ': "JWT"
}
payload
payload
用于存放有效信息,这些有效信息包含三个部分:
Registered claims
: 一组预定义的声明,建议但不强制使用。iss
:jwt
签发者sub
:jwt
所面向的用户aud
: 接收jwt
的一方exp
:jwt
的过期时间,这个过期时间必须要大于签发时间nbf
: 定义在什么时间之前,该jwt
都是不可用的.iat
:jwt
的签发时间jti
:jwt
的唯一身份标识,主要用来作为一次性token
,从而回避重放攻击。
Public claims
: 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.-
Private claims
: 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息1 2 3 4 5
{ "sub": '1234567890', "name": 'john', "admin":true }
对payload
进行Base64
编码就得到JWT
的第二部分
Signature
签名是用于验证消息在传递过程中有没有被更改,并且,对于使用私钥签名的token
,它还可以验证JWT
的发送方是否为它所称的发送方。
JWT
官网https://jwt.io/. 可以将生成的JWT token放里面进行验证
JWT VS OAuth VS OpenID
ASP.NET Core
在 Microsoft.AspNetCore.Authentication
下实现了一系列认证, 包含 Cookie
, JwtBearer
, OAuth
, OpenIdConnect
等,
Cookie
认证是一种比较常用本地认证方式, 它由浏览器自动保存并在发送请求时自动附加到请求头中, 更适用于 MVC
等纯网页系统的本地认证.
OAuth
& OpenID Connect
通常用于运程认证, 创建一个统一的认证中心, 来统一配置和处理对于其他资源和服务的用户认证及授权.
JwtBearer
认证中, 客户端通常将 JWT
(一种Token
) 通过 HTTP
的 Authorization header
发送给服务端, 服务端进行验证. 可以方便的用于 WebAPI
框架下的本地认证.
当然, 也可以完全自己实现一个WebAPI
下基于Token
的本地认证, 比如自定义Token
的格式, 自己写颁发和验证Token
的代码等. 这样的话通用性并不好, 而且也需要花费更多精力来封装代码以及处理细节.
如何使用JWT
在身份鉴定的实现中,传统方法是在服务端存储一个session
,给客户端返回一个cookie
,而使用JWT
之后,当用户使用它的认证信息登陆系统之后,会返回给用户一个JWT,用户只需要本地保存该token
(通常使用local storage
,也可以使用cookie
)即可。 当用户希望访问一个受保护的路由或者资源的时候,通常应该在Authorization
头部使用Bearer
模式添加JWT
.
因为用户的状态在服务端的内存中是不存储的,所以这是一种无状态的认证机制。服务端的保护路由将会检查请求头Authorization
中的JWT
信息,如果合法,则允许用户的行为。由于JWT
是自包含的,因此减少了需要查询数据库的需要。 JWT
的这些特性使得我们可以完全依赖其无状态的特性提供数据API
服务
初始化
创建Server工程
打开Visual Studio 2019, 创建一个新的工程,工程模板选择ASP.NET Core Web API
. 工程名字输入Login
,Target framework选择.net core 3.1
.
创建完成之后,Visutal Studio会为我们创建一个示例的Weather forcast
. Controller 文件夹下已经自动生成了一个WeatherForecastController
.
直接运行IIS Express
,会打开浏览器,地址为https://localhost:44323/weatherforecast
。显示内容为:
1
2
3
[{"date":"2021-12-02T15:47:17.5109281+08:00","temperatureC":-9,"temperatureF":16,"summary":"Mild"},{"date":"2021-12-03T15:47:17.5143102+08:00","temperatureC":38,"temperatureF":100,"summary":"Mild"},
{"date":"2021-12-04T15:47:17.5143142+08:00","temperatureC":25,"temperatureF":76,"summary":"Chilly"},{"date":"2021-12-05T15:47:17.5143145+08:00","temperatureC":-6,"temperatureF":22,"summary":"Warm"},
{"date":"2021-12-06T15:47:17.5143147+08:00","temperatureC":29,"temperatureF":84,"summary":"Sweltering"}]
我们会继续用这个示例。
由于我们Client端要用Angular
,这里对默认示例稍作修改.
WeatherForecastController.cs
1
2
3
[ApiController]
[Route("api/[controller]")]
public class WeatherForecastController : ControllerBase
加个前缀api
.
Properties\launchSettings.json
,修改默认url
1
2
3
4
5
6
7
8
9
10
11
12
"profiles": {
"IIS Express": {
...
"launchUrl": "api/weatherforecast",
...
},
"Login": {
...
"launchUrl": "api/weatherforecast",
...
}
}
重新运行IIS Express,会打开浏览器,地址为https://localhost:44323/api/weatherforecast
。依旧能够获取天气信息.
创建Angular工程
用Visual Studio code打开Login
文件夹,Terminal里输入ng new Client
创建Angular工程。输入y
创建Angular routing。
ng serve --open
,打开默认浏览器显示Angular默认界面http://localhost:4200/
创建module和component
创建新的module
: ng g m weather --routing
创建新的component
:ng g c weather/weather-details
创建新的service
: ng g service services/http
目录结构如下:
app-routing.module.ts
修改路由路径
1
2
3
4
5
6
7
8
9
10
11
const routes: Routes = [
{
path:'',
redirectTo: 'weather',
pathMatch:'full'
},
{
path:'weather',
loadChildren: () => import('./weather/weather.module').then(m => m.WeatherModule)
}
];
weather-routing.module.ts
中添加子路由
1
2
3
4
5
6
7
8
9
10
11
const routes: Routes = [
{
path:'weather-details',
component: WeatherDetailsComponent
},
{
path:'',
redirectTo: 'weather-details',
pathMatch: 'full'
}
];
这里用到了路由的懒加载,详情可以看Angular官方文档
删掉app.component.html
中默认的Angular
内容,添加router-outlet
1
<router-outlet></router-outlet>
重新运行client端,默认link到http://localhost:4200/weather/weather-details
,页面只有一句话
1
weather-details works!
访问Web Api
Get Weathers
在app.module.ts
中添加HTTPClientModule
引用
1
2
3
4
import { HttpClientModule } from '@angular/common/http';
imports: [
HttpClientModule,
],
在http.service.ts
中添加访问函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class HttpService {
baseUrl = './api/';
constructor(private http:HttpClient) { }
GetWeathers():Observable<any>{
return this.http.get(this.baseUrl + 'weatherforecast');
}
}
添加proxy
在Client
根目录下创建一个代理文件proxy.conf.json
1
2
3
4
5
6
7
{
"/api/*":{
"target":"https://localhost:44323/",
"secure":false,
"changeOrigin":true
}
}
修改package.json
1
"start": "ng serve",
改成
1
"start": "ng serve --proxy-config proxy.conf.json",
我在http.service.ts
没有输入完整的server地址,所以加了这个代理文件,当发送http
请求时,所有以api
打头的请求会自动转向https://localhost:44323
.
这个东西还是挺有用的,尤其是client和Server不在同一个domain
时。会有CORS(Cross-Origin Resource Sharing)
问题
针对CORS
,我们可以在web api里添加Cors
,但这只能解决一部分问题。我们再后续设置Cookie的时候还很麻烦。Web Api默认是不给跨domain的请求设置Cookie,所以在这里我们直接用代理方式比较省事。
调用服务
在weather-details.component.ts
中调用服务获取天气:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export class WeatherDetailsComponent implements OnInit {
weatherList:any[] = [];
constructor( private httpService: HttpService) { }
ngOnInit(): void {
this.GetWeatherFromServer();
}
GetWeatherFromServer():void {
this.httpService.GetWeathers().subscribe(
(res:any) => {
this.weatherList = res;
},err=>{
console.log(err);
}
)
}
}
创建一个weatherList
存储从web api
里拿到的天气信息。
创建函数GetWeatherFromServer
调用httpService
服务。在页面初始化ngOnInit
的时候调用。
页面显示
修改weather-details.component.html
,显示天气信息
1
2
3
4
5
6
7
8
9
10
11
<ul>
<li *ngFor="let item of weatherList">
{ {item.date} } // 去掉括号间的空格,markdown显示有问题,不支持双括号
<br>
{ {item.temperatureC} }
<br>
{ {item.temperatureF} }
<br>
{ {item.summary} }
</li>
</ul>
运行web api, 用命令npm start
运行client,浏览器应该能显示以下`内容:
Angular client
能够调用http
服务从Web Api
获取天气数据。
至此,我们初始化的工作全部完成。
添加授权
Web API
添加JWT引用
在Web api 工程 Dependencies
上右键,Manage Nuget package
, 搜索Jwtbearer
.
选中 Microsoft.AspNetCore.Authentication.JwtBearer
, 版本选3.1.21
安装,过程中需要同意协议
添加Authorize服务
添加文件夹Extensions
,添加新的类 AuthServiceExtension.cs
修改为静态类,并且实现以下方法
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
public static IServiceCollection AddAuthService(this IServiceCollection services, IConfiguration config)
{
services.AddAuthorization()
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(option =>
{
option.TokenValidationParameters = new TokenValidationParameters
{
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
ValidateAudience = true,
AudienceValidator = (m, n, z) =>
{
return m != null && m.FirstOrDefault().Equals("your audience");
},
ValidateIssuer = true,
ValidIssuer = "your issuer",
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your security key"))
};
});
return services;
}
修改Startup.cs
,添加Authorize服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddAuthService(Configuration);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
...
}
修改WeatherForecastController
在Get
方法上添加Authorize
标志
1
2
3
[HttpGet]
[Authorize]
public IEnumerable<WeatherForecast> Get()
重新运行web api,浏览器打开后返回401
错误,刷新Client,没法再获取天气信息了,这是我们所期望的,我们已经添加了授权,所以没法直接访问天气信息了。
在chrome console里有以下提示:
1
Failed to load resource: the server responded with a status of 401 (Unauthorized)
添加User Model
在Models
文件夹下添加新的Model User
, 添加两个属性用以记录用户名和密码:
1
2
3
4
5
public class User
{
public string UserName { get; set; }
public string Password { get; set; }
}
添加Account Controller
在Controllers
文件夹下添加新的controller AccountController.cs
,用于登录,添加login
和logout
方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[ApiController]
[Route("api/[controller]/[action]")]
public class AccountController : Controller
{
[HttpPost]
public async Task<IActionResult> Login([FromBody] User user)
{
return Ok();
}
[HttpGet]
public IActionResult Logout()
{
return Ok();
}
}
当用户访问login
时,我们要生成新的JWT token
,所以接下来添加token service
:
Token Service
新建Services
文件夹,添加ITokenService
接口:
1
2
3
4
public interface ITokenService
{
public string GetToken(User user);
}
添加实现类TokenService
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TokenService : ITokenService
{
public string GetToken(User user)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.AddMinutes(30)).ToUnixTimeSeconds()}"),
new Claim(ClaimTypes.NameIdentifier, user.UserName.ToString()),
new Claim("Name", user.UserName.ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your security key"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: "your issuer",
audience: "your audience",
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds,
claims: claims
);
var jwtToken = new JwtSecurityTokenHandler().WriteToken(token);
return jwtToken;
}
}
在Startup
里注册此服务
1
2
3
4
5
6
7
8
9
10
11
12
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<ITokenService, TokenService>();
services.AddControllers();
services.AddAuthService(Configuration);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
}
修改AccountController/Login
方法,调用TokenService
服务:
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
public class AccountController : Controller
{
private readonly ITokenService tokenService;
public AccountController(ITokenService tokenService)
{
this.tokenService = tokenService;
}
[HttpPost]
public async Task<IActionResult> Login([FromBody] User user)
{
IActionResult response = Unauthorized();
// Connect remote server to validate user name and password
bool bValidated = await ValidateUserByRemoteServer(user);
if (bValidated)
{
var token = this.tokenService.GetToken(user);
Response.Cookies.Append("Authorization", token);
Response.Cookies.Append("UserName", user.UserName);
response = Ok();
}
return response;
}
private Task<bool> ValidateUserByRemoteServer(User user)
{
return Task.FromResult(true);
}
[HttpGet]
public IActionResult Logout()
{
return Ok();
}
}
因为我需要调用另外一个服务器去验证用户名和密码,所以我加了一个额外的异步函数。
当验证成功后,把Token
塞到Response
的Cookie
里,这样做的好处是Client端不需要做任何操作,之后的请求会自动带着token。
还要一种做法是把Token放到消息的body里,这样Client收到之后可以自己选择处理的方式,存到Local Storage
里或者Cookie
里都可以。这样做Client端可以更灵活的处理token,但也带来了窃取的风险。
运行Web api,这时我们可以用Postman
测试我们的服务.
Postman测试
打开Postman
,新建一个request, 类型改为POST
,地址为https://localhost:44323/api/Account/Login
在请求的body里添加我们的用户名和密码,格式为raw - JSON
点击发送,我们能收到一个response,body为空,查看Cookies
能看到我们的token,复制出来。
新建一个request,类型为GET
,地址为https://localhost:44323/api/weatherforecast
在Header里添加新的KEY -VALUE pair
Key: Authorization
VALUE: Bearer [Token]
点击发送,应该能收到天气信息了。
至此,server端已经能够处理授权了。我们之后还会针对Server端做一部分的优化工作。先搞定client…
Angular Client
添加login http函数
生成新的service: ng g service services/account
在account.service.ts
里添加login和Logout函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export class AccountService {
loginUrl = './api/Account/Login';
logoutUrl = './api/Account/Logout';
redirectUrl = '/weather/weather-details'
constructor(private http:HttpClient, private router:Router) { }
Login(UserInf:any):void{
this.http.post(this.loginUrl, UserInf).subscribe(
() => {
this.router.navigateByUrl(this.redirectUrl, {replaceUrl:true})
}, error => {
console.log(error);
});
}
Logout():void{
this.http.get(this.logoutUrl).subscribe(
() =>{
this.router.navigateByUrl('/account/login');
});
}
}
login成功之后自动跳转到weather页面
创建login页面
接下来创建Login界面
生成Account module: ng g m account --routing
生成login component: ng g c account/login
在login.component.html
里添加表单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<p>login works!</p>
<form (ngSubmit)="Login()" autocomplete="off">
<h2>Updater Login</h2>
<div>
<input type="text" name="username" [(ngModel)]="UserInfo.UserName" placeholder="User name">
</div>
<div>
<input type="password" name="password" [(ngModel)]="UserInfo.Password" placeholder="Password">
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
在account.module.ts
里引入FormsModule
修改login.component.ts
1
2
3
4
5
6
7
8
9
10
11
export class LoginComponent implements OnInit {
UserInfo:any = {};
constructor(private accountService: AccountService) { }
ngOnInit(): void { }
Login():void{
console.log(this.UserInfo);
this.accountService.Login(this.UserInfo);
}
}
修改路由
app-routing.module.ts
里添加新路由,并且修改default
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const routes: Routes = [
{
path:'',
redirectTo: 'account',
pathMatch:'full'
},
{
path:'weather',
loadChildren: () => import('./weather/weather.module').then(m => m.WeatherModule),
canActivate:[LoginGuard]
},
{
path:'account',
loadChildren: () => import('./account/account.module').then(m => m.AccountModule),
canActivate:[LogoutGuard]
}
];
account-routing.module.ts
里添加路由
1
2
3
4
5
6
7
8
9
10
11
const routes: Routes = [
{
path:'login',
component:LoginComponent
},
{
path:'',
redirectTo:'login',
pathMatch:'full'
}
];
运行npm start
, 打开http://localhost:4200/
, 自动转到account/login
。随便输入一个用户名和密码,登录
页面会跳转到weather,但是没有数据显示。
按F12
,在Application
- Storage
- Cookies
里面已经有了客户端 http://localhost:4200
,选中之后,右边会有UserName
和Authorization
,这说明我们已经授权通过,拿到token了,只是在接下来获取weather的时候出了问题。
修改Web Api
在AuthServiceExntension.cs
里添加option.Events
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
public static IServiceCollection AddAuthService(this IServiceCollection services, IConfiguration config)
{
services.AddAuthorization()
.AddAuthentication(x =>
{
...
})
.AddJwtBearer(option =>
{
option.TokenValidationParameters = new TokenValidationParameters
{
...
};
option.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Cookies.ContainsKey("Authorization"))
{
context.Token = context.Request.Cookies["Authorization"];
}
return Task.CompletedTask;
}
};
});
return services;
}
Web Api在收到Client的weather请求时,没有处理存在Cookie里的token,所以我们要加个event,当请求到达时,把Cookie里的token取出来,显示赋值一下。
重新运行web api,输入用户名密码,页面跳转之后能够拿到天气信息了。
做到这其实授权基础部分已经做完了,我们可以继续做一些优化工作。
优化
Web API
双Token
之前在查资料的时候发现有些人建议双token: Access Token
+ Refresh Token
. Access Token
设定的很短,比如十分钟,Refresh Token
设定的很长,比如一周或者一个月。当用户申请授权的时候会同时收到两个token,之后的请求,用户只需要携带时间短的Access Token
,当这个token过期之后,用户需要携带Refresh token
,Server会重新生成一个Access token
给用户。
这样做的好处有:
Access Token
时效很短,也就更加安全。黑客即便截取到了,也只有十分钟的有效期。
用户平时的请求只携带Access Token
,Refresh Token
之有刷新的时候才会用到。比只用Access Token
更加安全。
我在试验的时候没有用这个方式,留待以后研究。
Token自动刷新
之前我们token的设定是30分钟后自动过期,如果用户在这30分钟内一直在操作,到30钟时就突然过期了,这是个很不友好的设定。
这里稍作改进,当Server收到客户端请求之后,如果验证通过,会重新生成一个token给客户端。这样就变成了用户30分钟不操作之后token会过期。
添加Refresh Token
函数:
ITokenService.cs
1
2
3
4
5
public interface ITokenService
{
public string GetToken(User user);
public void RefreshJwtToken(TokenValidatedContext context);
}
TokenService.ts
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
public void RefreshJwtToken(TokenValidatedContext context)
{
string tokenString = context.Request.Cookies["Authorization"];
User user = ReadToken(tokenString);
var jwt = GetToken(user);
try
{
context.Response.Cookies.Append("Authorization", jwt);
}
catch (Exception ex)
{
Console.WriteLine("Error occured during refresh token. " + ex);
}
}
private User ReadToken(string tokenString)
{
User user = new User();
JwtSecurityToken token = new JwtSecurityTokenHandler().ReadJwtToken(tokenString);
foreach (Claim claim in token.Claims)
{
if (claim.Type == "Name")
{
user.UserName = claim.Value;
}
}
return user;
}
修改AuthServiceExtension.cs
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
39
40
41
public static IServiceCollection AddAuthService(this IServiceCollection services, IConfiguration config)
{
ITokenService tokenService = services.BuildServiceProvider().GetRequiredService<ITokenService>();
services.AddAuthorization()
.AddAuthentication(x =>
{
...
})
.AddJwtBearer(option =>
{
option.TokenValidationParameters = new TokenValidationParameters
{
...
};
option.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
...
},
OnTokenValidated = context =>
{
Task.Run(() =>
{
try
{
tokenService.RefreshJwtToken(context);
}
catch (Exception ex)
{
Console.WriteLine("Failed to refresh token " + ex);
}
});
return Task.CompletedTask;
}
};
});
return services;
}
做个试验,可以将token的有效期改为30秒,如果30秒不做任何动作,token就会过期,拿不到天气数据。
如果在30秒内刷新一下,token就会再延长30秒。
Cookie Configuration
我们在返回token的时候将token塞到了Cookie里,为了更好的保护token,这里对cookie做一些安全设定。
ITokenService
1
public CookieOptions GetCookieSetting(HttpRequest request, bool httpOnly);
TokenService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public CookieOptions GetCookieSetting(HttpRequest request, bool httpOnly)
{
return new CookieOptions()
{
HttpOnly = httpOnly,
SameSite = SameSiteMode.Strict,
Domain = request.Host.Host
};
}
public void RefreshJwtToken(TokenValidatedContext context)
{
...
try
{
context.Response.Cookies.Append("Authorization", jwt, GetCookieSetting(context.Request, true));
}
catch (Exception ex)
{
Console.WriteLine("Error occured during refresh token. " + ex);
}
}
AccountController
1
2
3
4
5
6
7
8
9
10
11
12
public async Task<IActionResult> Login([FromBody]User user)
{
...
if (bValidated)
{
...
Response.Cookies.Append("Authorization", token, this.tokenService.GetCookieSetting(Request, true));
Response.Cookies.Append("UserName", user.UserName, this.tokenService.GetCookieSetting(Request, false));
...
}
return response;
}
当我们设置HttpOnly
为true
的时候,客户端的JavaScripts不能对Cookie中的token进行操作。用document.cookie
读取token时会返回null
检查一下Client端获取的token,会发现HTTPOnly
和Strict
字样。
添加JWTSetting
之前生成token的时候,我们用的SecurityKey
,Issuer
和Audience
都是直接hard code在代码里了,将它们提取到配置文件里。
在Models,添加新的类 JwtSettings.cs
添加三个属性
1
2
3
4
5
6
public class JwtSettings
{
public string Issuer { get; set; }
public string Audience { get; set; }
public string SecurityKey { get; set; }
}
在appsettings.json
中添加配置信息
1
2
3
4
5
"JwtSettings": {
"SecurityKey": "your security key",
"Issuer": "your issuer",
"Audience": "your audience"
}
在Startup
中读取配置信息并添加到Options里
1
2
3
4
5
6
7
8
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<ITokenService, TokenService>();
services.AddOptions()
.Configure<JwtSettings>(Configuration.GetSection(nameof(JwtSettings)));
services.AddControllers();
services.AddAuthService();
}
AuthServiceExtension
中就可以调用了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static IServiceCollection AddAuthService(this IServiceCollection services)
{
IOptions<JwtSettings> jwtSettings = services.BuildServiceProvider().GetRequiredService<IOptions<JwtSettings>>();
...
services.AddAuthorization()
.AddAuthentication(x =>
{
...
})
.AddJwtBearer(option =>
{
option.TokenValidationParameters = new TokenValidationParameters
{
...
return m != null && m.FirstOrDefault().Equals(jwtSettings.Value.Audience);
...
ValidIssuer = jwtSettings.Value.Issuer,
...
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Value.SecurityKey))
};
...
});
...
}
TokenService
里
1
2
3
4
5
6
7
8
9
10
11
12
13
public class TokenService : ITokenService
{
private readonly JwtSettings _jwtSettings;
public TokenService(IOptions<JwtSettings> option)
{
_jwtSettings = option.Value;
}
public string GetToken(User user)
{
...
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecurityKey));
...
}
看起来有点费劲,但将配置信息放到配置文件里,这是个好习惯。
添加Logout
在AccountController里添加logout内容,删掉cookie里的token信息。
1
2
3
4
5
6
7
[HttpGet]
public IActionResult Logout()
{
Response.Cookies.Delete("Authorization", this.tokenService.GetCookieSetting(Request, true));
Response.Cookies.Delete("UserName", this.tokenService.GetCookieSetting(Request, false));
return Ok();
}
Angular Client
路由守卫
Angular Client端目前有两个问题:
运行npm start
, 当我们直接在url里输入http://localhost:4200/weather/weather-details
时,发现也能调出页面,虽然没有从服务端获取到天气数据。
期望的行为是当页面导航到weather/weather-details
前,检查一下是否已经登录,如果没有,redirect到登录页面。
第二个问题: 当我们输入用户名,密码,页面跳转到天气页面后,在url里输入http://localhost:4200/account/login
,发现也能跳转到登录页面。
这也不是我们期望的行为,用户已经登录了,没有必要再登录一次。在页面导航到account/login
前,检查一下是之前是否已经登录了。如果是,redirect到天气页面。
这两个问题的解决方法是加路由守卫。
添加login.guard: ng g guard guards/login
,选择实现 canActivate
1
2
3
4
5
6
7
8
9
10
11
12
13
export class LoginGuard implements CanActivate {
constructor(private accountService:AccountService, private router:Router){}
canActivate(): boolean {
let bAuthorized = this.accountService && this.accountService.GetCurrentUser() != null;
if(!bAuthorized)
{
this.router.navigateByUrl('account');
return false;
}
return true;
}
}
在 AccountService
里添加函数
1
2
3
4
5
6
7
8
GetCurrentUser():any{
let arr;
if(arr = document.cookie.match(new RegExp("(^| )UserName=([^;]*)(;|$)")))
{
return unescape(arr[2]);
}
return null;
}
在app-routing.module.ts
里添加此守卫
1
2
3
4
5
{
path:'weather',
loadChildren: () => import('./weather/weather.module').then(m => m.WeatherModule),
canActivate:[LoginGuard]
},
添加logout.guard: ng g guard guards/logout
,选择实现 canActivate
1
2
3
4
5
6
7
8
9
10
11
12
export class LogoutGuard implements CanActivate {
constructor(private accountService:AccountService, private router:Router){}
canActivate(): boolean {
let isAuthorized = this.accountService && this.accountService.GetCurrentUser() != null;
if(isAuthorized){
this.router.navigateByUrl('weather');
return false;
}
return true;
}
}
在app-routing.module.ts
里添加此守卫
1
2
3
4
5
{
path:'account',
loadChildren: () => import('./account/account.module').then(m => m.AccountModule),
canActivate:[LogoutGuard]
}
拦截器Interceptor
在Angular发送http请求前和收到response后,我们希望加入一些操作。
发送http前设置一下header,我们可以在每个http请求里都进行设置,更方便的做法是加个http拦截器统一进行设定。
当收到response时,检查一下status,如果是401,那可能是token超时了,我们就直接redirect到登录界面。
生成httpsInterceptor:ng g interceptor interceptors/https
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export class HttpsInterceptor implements HttpInterceptor {
constructor(private accountService:AccountService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
let headers = request.headers;
headers = headers.set("Content-Type", "application/json");
return next.handle(request).pipe(
tap(
event => {
// trace
}, error =>{
if(error.status === 401){
console.log("User session end already!");
this.accountService.Logout();
}
}
)
);
}
}
在app.module.ts
里注册
1
2
3
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: HttpsInterceptor, multi: true },
],
如果用户在weather-details页面超过30分钟没有操作,再次刷新时会返回401,这时会转到登录界面让用户重新输入用户名密码。
总结
写到这里该做的就差不多做完了。用到了很多Angular的知识点,感觉收获很多。
附上完整代码地址:
JwtLogin Github
References
五分钟带你了解啥是JWT
什么是 JWT – JSON WEB TOKEN
ASP.NET Core WebAPI中使用JWT Bearer认证和授权
ASP.NET Core WebAPI中使用JWT Bearer认证和授权
ASP.NET Core Web Api之JWT VS Session VS Cookie(二)
ASP.NET Core Web Api之JWT刷新Token(三)
拦截请求和响应
Asp.Net Core 5 REST API 使用 RefreshToken 刷新 JWT - Step by Step
ASP.NET Core 3.1 API - JWT Authentication with Refresh Tokens
JWT Authentication Flow with Refresh Tokens in ASP.NET Core Web API
Best Practices for JWT Authentication in Angular Apps
Angular Authentication With JSON Web Tokens (JWT): The Complete Guide
Run Angular and ASP.NET Web API on the same port
防止未经授权的访问
Angular 从入坑到挖坑 - 路由守卫连连看