上一篇教程我们讲到了要做一个后台管理系统的项目,任何要使用这个系统的人都必须先登录,所以本篇就来说明一下如何做登录拦截。从本篇教程开始,前端的代码会和后台做通信,后台使用的是我在另一个系列教程Springboot入门教程中的后台代码,建议使用我在github上面的代码https://github.com/ahuadoreen/studentmanager搭建后台。
- 首先我们需要新建一个login组件,为了方便,我们可以直接利用ng-zorro官方的login示例组件来生成之后再做修改。先到https://ng.ant.design/components/form/zh,找到login示例组件如图
点击下方四个图标按钮中的第三个“复制生成代码命令”,之后去idea的最下方找Terminal那一栏,粘贴命令,需要修改的是要输入组件名称login,完整的命令是ng g ng-zorro-antd:form-normal-login login,然后回车运行。
有可能你会遇到“More than one module matches. Use skip-import option to skip importing the component into the closest module.”的错误,那是因为我们之前在引入ng-zorro的时候多生成了一个IconProvideModule,这里我们可以把它删掉,把app.module.ts中引入的代码也删掉,详情可以参考angular8教程(2)-引入ng-zorro。之后再运行应该就能成功了。自动生成的组件会被直接导入到app.module.ts中,不需要我们手动导入。 - 接着需要新建一个路由守卫,它的功能是使未登录的用户强制跳转到登录页面,对于这样的功能,我们需要实现的是一个canActive的守卫(关于路由守卫的详细介绍可以查看官方文档路由守卫)。
在app文件夹上右键,依次选择New->Angular Schematic->guard,输入login,点击OK。之后在控制台需要选择继承的类,按空格键后选中canActive再按回车,就生成了一个login.guard.ts的文件 。
修改代码,写入拦截未登录的逻辑
import { Injectable } from '@angular/core';
import {CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router} from '@angular/router';
@Injectable({
providedIn: 'root'
})
export class LoginGuard implements CanActivate {
constructor(private router: Router) {
}
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): boolean {
let isLogin: boolean;
// 判断用户是否登录
const token = sessionStorage.getItem('token');
if (!token) {
isLogin = false;
// 未登录跳转到登录界面
this.router.navigateByUrl('/login');
} else {
isLogin = true;
}
return isLogin;
}
}
这里主要是添加了一个构造函数constructor,利用依赖注入的特性声明了一个router的路由变量(关于依赖注入的概念见官方文档依赖注入)。这是angular中一个非常重要且有用的特性,如果学习过java后台的spring框架的朋友相信对这个概念已非常熟悉,如果没有学过,那也不要紧,在后面的使用中可以慢慢熟悉起来。
我们设定用户登录后会获取后台返回的一个token保存在sessionStorage中,所以这里我们判断如果sessionStorage中没有token,那说明用户没有登录过,则跳转到登录页面,否则返回true,允许直接跳转到目标页面。
- 修改路由配置文件app-routing.module.ts。
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: '/home' },
{ path: 'home', component: HomeComponent, canActivate: [LoginGuard] },
{ path: 'login', component: LoginComponent}
];
这里我们给home组件的路由添加一个canActivate指向LoginGuard,再添加一个login的路由配置。
接着我们可以运行项目,访问localhost:4200,可以看到这次不会直接跳转到home页面,而是跳转到了login页面。
接下来我们要修改login组件来完成登录逻辑。
- 先修改一下界面,修改样式文件login.component.css中的login-form样式
.login-form {
-webkit-border-radius: 5px;
border-radius: 5px;
-moz-border-radius: 5px;
background-clip: padding-box;
width: 350px;
padding: 35px 35px 15px 35px;
background: #fff;
position: absolute;
left: 50%;
top: 50%;
margin-left: -200px;
margin-top: -200px;
border: 1px solid #eaeaea;
box-shadow: 0 0 25px #cac6c6;
}
修改布局文件login.component.html
- 我们需要创建一个service层用来处理各种http请求,先新建一个service路径,再在里面新建一个login.service.ts文件,代码如下:
import { Injectable } from '@angular/core';
import {Observable} from 'rxjs';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {catchError} from 'rxjs/operators';
import {of} from 'rxjs/internal/observable/of';
import qs from 'qs';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
};
@Injectable({
providedIn: 'root'
})
export class LoginService {
private loginUrl = '/studentmanage/login';
constructor(private http: HttpClient) { }
login(username: string, password: string): Observable {
const options = { username, password };
return this.http.post(this.loginUrl, qs.stringify(options), httpOptions)
.pipe(
catchError(this.handleError('login', username))
);
}
private handleError(operation = 'operation', result?: T) {
return (error: any): Observable => {
// TODO: send the error to remote logging infrastructure
console.error(error); // log to console instead
// Let the app keep running by returning an empty result.
return of(result as T);
};
}
}
这里我们给component层提供了一个login的接口,用来发送login的post请求。angular给我们提供了现成的http请求的类HttpClient用来处理各种http请求,具体参见官方文档HttpClient。这里需要注意的是请求参数格式的问题,由于我的后台接收的是Content-Type为application/x-www-form-urlencoded格式的参数,而angular默认的Content-Type为application/json,也因为如此,我用了qs格式化了请求参数。
- 修改login.component.ts的逻辑代码
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import {Router} from '@angular/router';
import {LoginService} from '../service/login.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
validateForm: FormGroup;
submitForm(): void {
for (const i in this.validateForm.controls) {
this.validateForm.controls[i].markAsDirty();
this.validateForm.controls[i].updateValueAndValidity();
if (this.validateForm.controls[i].invalid) {
return;
}
}
this.loginService.login(this.validateForm.value.username, this.validateForm.value.password)
.subscribe(result => this.loginSuccess(result));
}
loginSuccess(result: any) {
console.log('result: ' + JSON.stringify(result));
const data = result.data;
const token = data.token;
const username = data.username;
sessionStorage.setItem('token', token);
sessionStorage.setItem('username', username);
this.router.navigate(['/home']);
}
constructor(private fb: FormBuilder, private router: Router, private loginService: LoginService) {}
ngOnInit(): void {
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
this.validateForm = this.fb.group({
username: ['admin', [Validators.required]],
password: ['qwertyuiop', [Validators.required]],
remember: [true]
});
}
}
这里我们利用依赖注入的特性调用创建的login service来完成login请求,请求之前的默认数据绑定和数据校验angular的form模块也提供了现成的api来完成。
到这里登录的逻辑已经完成了,但是我们还无法访问后台,这是因为根据同源策略,我们的http请求必然会发生跨域的问题,那么怎么解决呢。在开发阶段,我们可以用以下方式:
- 在项目根目录下创建一个proxy.conf.json的文件,添加如下配置
{
"/studentmanage": {
"target": "http://localhost:8080",
"secure": false
}
}
这里的配置代表匹配"/studentmanage"这个路径的请求都会转到target的路径下,所以我们在login.service的请求路径中只写了"/studentmanage/login",实际最终请求会被转发到"http://localhost:8080/studentmanage/login"这个完整的路径下。
- 修改package.json中的启动配置,将scripts中的start配置改为
"start": "ng serve --proxy-config proxy.conf.json"
完成后重新启动项目,如果你的后台已经正常运行了, 那么点击登录按钮就能成功登录进入home界面。
到这里还没有结束,我们还有一项工作要做。完成登录逻辑之后,我们拿到了后台返回的token并保存到了sessionStorage中,后面的其他请求我们都需要带着这个token,因此我们需要给除了login请求之外的其他请求做一个拦截,让它们统一带上这个token。
- 新建一个http请求的拦截器MyInterceptor,它需要继承HttpInterceptor。
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
HttpResponse,
HttpResponseBase
} from '@angular/common/http';
import {Observable, throwError} from 'rxjs';
import {Router} from '@angular/router';
import {of} from 'rxjs/internal/observable/of';
import {mergeMap} from 'rxjs/internal/operators/mergeMap';
import {catchError, retry} from 'rxjs/operators';
export class MyInterceptor implements HttpInterceptor {
constructor(private router: Router) {}
intercept(req: HttpRequest, next: HttpHandler): Observable> {
let authReq: any;
// 实现不拦截的方式:1. 指定接口不拦截 2. 判断本地sessionStorage
if (!req.url.includes('login')) {
const token = sessionStorage.getItem('token');
const username = sessionStorage.getItem('username');
console.log(token + username);
authReq = req.clone({ setHeaders: { token, username } });
return next.handle(authReq).pipe(
mergeMap((event: any) => {
// 允许统一对请求错误处理
if (event instanceof HttpResponse) {
const body: any = event.body;
console.log(body);
if (body.code !== 200) {
// 继续抛出错误中断后续所有 Pipe、subscribe 操作,因此:
// this.http.get('/').subscribe() 并不会触发
if (body.code === 401) {// token过期,并且无法刷新,需要重新登录
this.router.navigate(['/login']);
} else if (body.code === 100) {// token过期,服务器端自动刷新了token,需要用新的token重新发送请求
const newToken = body.token;
sessionStorage.setItem('token', newToken);
console.log('new token: ' + newToken);
authReq = req.clone({ setHeaders: { token: newToken, username } });
return next.handle(authReq).pipe();
}
return throwError({});
} else {
// 重新修改 `body` 内容为 `response` 内容,对于绝大多数场景已经无须再关心业务状态码
// return of(new HttpResponse(Object.assign(event, { body: body.response })));
// 或者依然保持完整的格式
return of(event);
}
} else {
return of(event);
}
}),
catchError((err: HttpErrorResponse) => this.handleData(err))
);
}
authReq = req.clone();
return next.handle(authReq);
}
private handleData(ev: HttpResponseBase): Observable {
// 可能会因为 `throw` 导出无法执行 `_HttpClient` 的 `end()` 操作
console.log(ev.status);
return of(ev);
}
}
这里我们判断如果是login请求便不需要拦截,其他请求则将username和token放到header中发送。并且也统一拦截了返回的数据先统一处理,这里重点处理了两个状态,一个是401需要重新登录,另一个是100,服务器端返回了新的token,这个拦截器保存了前一次请求的数据在req参数中,所以我们只需要把新的token重新放入header中再次做req.clone就可以了。
- 在app.module.ts中配置,这个拦截器需要配置到providers中
providers: [{ provide: NZ_I18N, useValue: zh_CN }, { provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }],
至此,登录拦截的全部功能才算完成。
代码依然可以参考https://github.com/ahuadoreen/studentmanager-cli。