Web

ASP.NET Core WebAPI + Angular 使用JWT Bearer认证

Posted by Kerwen Blog on December 2, 2021

最近接手新的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典型的由两部分组成: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放里面进行验证
img

JWT VS OAuth VS OpenID

ASP.NET CoreMicrosoft.AspNetCore.Authentication 下实现了一系列认证, 包含 Cookie, JwtBearer, OAuth, OpenIdConnect 等,
Cookie 认证是一种比较常用本地认证方式, 它由浏览器自动保存并在发送请求时自动附加到请求头中, 更适用于 MVC 等纯网页系统的本地认证.
OAuth & OpenID Connect 通常用于运程认证, 创建一个统一的认证中心, 来统一配置和处理对于其他资源和服务的用户认证及授权.
JwtBearer 认证中, 客户端通常将 JWT(一种Token) 通过 HTTPAuthorization 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

创建新的moduleng g m weather --routing
创建新的componentng g c weather/weather-details
创建新的service: ng g service services/http
目录结构如下:
img

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,浏览器应该能显示以下`内容:

img

Angular client能够调用http服务从Web Api获取天气数据。
至此,我们初始化的工作全部完成。

添加授权

Web API

添加JWT引用

在Web api 工程 Dependencies上右键,Manage Nuget package, 搜索Jwtbearer.
选中 Microsoft.AspNetCore.Authentication.JwtBearer, 版本选3.1.21 img

安装,过程中需要同意协议

添加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,用于登录,添加loginlogout方法

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塞到ResponseCookie里,这样做的好处是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,复制出来。
img

新建一个request,类型为GET,地址为https://localhost:44323/api/weatherforecast
在Header里添加新的KEY -VALUE pair

Key: Authorization
VALUE: Bearer [Token]

点击发送,应该能收到天气信息了。
img

至此,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,选中之后,右边会有UserNameAuthorization,这说明我们已经授权通过,拿到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 TokenRefresh 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秒。

我们在返回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;
}

当我们设置HttpOnlytrue的时候,客户端的JavaScripts不能对Cookie中的token进行操作。用document.cookie读取token时会返回null
检查一下Client端获取的token,会发现HTTPOnlyStrict字样。
img

添加JWTSetting

之前生成token的时候,我们用的SecurityKeyIssuerAudience都是直接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 从入坑到挖坑 - 路由守卫连连看