目录
一. 登录前的验证码校验功能
1. 添加滑动校验 Angular 组件
1.1?verifySlipping.component.html
1.2?verifySlipping.component.css
1.3?verifySlipping.component.ts
1.4 verify.js
1.5?aes-encrypt-decrypt.ts
1.6?verify.service.ts
2. 添加登录时的验证码校验逻辑
2.1 保持 loginService 服务为单例,避免路由守卫出 bug
2.2 获取密钥时,Angular Http 请求发送失败?
2.3 点击登录按钮,初始化验证码
2.4 初始化验证码
2.5?验证码校验成功后 执行真正的登录
2.6 效果展示
二. 修改密码功能
1. 进入页面后,加密登录名,获取新密钥
2. 校验密码,要求包含大小写字母、数字、特殊字符
3. 修改密码,传参格式 FORM['USER_ID'] 的写法
一. 登录前的验证码校验功能
验证码校验功能参考开源项目?AJ-Captcha
将验证码功能,嵌入你的项目里,需要添加以下依赖:
yarn add?crypto-js
yarn add?jwt-decode
yarn add?jquery
1. 添加滑动校验 Angular 组件
AJ-Captcha: 行为验证码(滑动拼图、点选文字),前后端(java)交互,包含vue/h5/Android/IOS/flutter/uni-app/react/php/微信小程序的源码和实现https://gitee.com/anji-plus/captcha
三点说明:
- 我下载了上面仓库中的 Angular 前端示例,使用 ng serve 运行项目
- 此示例和我项目中的实际逻辑,区别较大,我几乎只用到了组件
- 需要参考的前提:项目后端系统已经配备了相应后端接口
下面介绍下验证码组件相关内容
1.1?verifySlipping.component.html
注释掉的部分:验证码提示框的头部,一般情况下用不上,会导致显示混乱
此处需要注意两个ID,这俩 ID 在组件中会被 jquery 操作控制显隐:
- mpanel1:用于盛放验证码图片、拼图、滑块之类的容器
- slipping:用于标识这是 滑动校验组件,不是 点选文字校验组件
文件地址:src\app\components\verify\verifySlipping.component.html
<div
class="modal fade"
id="slipping"
tabindex="-1"
role="dialog"
aria-labelledby="myLargeModalLabel"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<!-- <div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">请完成滑动安全验证</h5>
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">×</span>
</button>
</div> -->
<div class="modal-body">
<div class="box">
<div class="verifybox-top">
<div class="box">
<div id="mpanel1"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
1.2?verifySlipping.component.css
验证码弹框组件的基本样式
注意:验证码弹框的宽度是写死的,这会导致屏幕小的手机,显示不全验证码弹框
由于时间紧促,我没研究改哪些宽度百分比,而是直接使用了 scale(0.85) 缩小移动端弹框
文件地址:src\app\components\verify\verifySlipping.component.css
.btn {
border: none;
outline: none;
width: 300px;
height: 40px;
line-height: 40px;
text-align: center;
cursor: pointer;
background-color: #409eff;
color: #fff;
font-size: 16px;
letter-spacing: 1em;
}
.modal-dialog {
width: 466px;
}
global.scss 下,补充了全局验证码弹框样式
之前打算使用 important 强行覆盖 jq?动态写入的验证码弹框宽度,后来因为要修改的地方太多,懒得改了,所以采用了?transform: translate(-50%, -50%) scale(0.85);
文件地址:src\global.scss
/**
* 安全验证
*/
.verify-code {
font-size: 20px;
text-align: center;
cursor: pointer;
margin-bottom: 5px;
border: 1px solid #ddd;
}
.cerify-code-panel {
height: 100%;
overflow: hidden;
}
.verify-code-area {
float: left;
}
.verify-input-area {
float: left;
width: 60%;
padding-right: 10px;
}
.verify-change-area {
line-height: 30px;
float: left;
}
.varify-input-code {
display: inline-block;
width: 100%;
height: 25px;
}
.verify-change-code {
color: #337ab7;
cursor: pointer;
}
.verify-btn {
width: 200px;
height: 30px;
background-color: #337ab7;
color: #ffffff;
border: none;
margin-top: 10px;
}
.verifybox {
position: relative;
box-sizing: border-box;
border-radius: 2px;
background-color: #fff;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.85);
}
.verifybox-top {
padding: 0 15px;
/* height: 50px; */
line-height: 50px;
text-align: left;
font-size: 16px;
color: #45494c;
box-sizing: border-box;
}
.verifybox-bottom {
padding: 15px;
box-sizing: border-box;
}
.verifybox-close {
position: absolute;
top: 13px;
right: 9px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
cursor: pointer;
}
.mask {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.3);
/* display: none; */
transition: all 0.5s;
display: none;
}
.verify-tips {
position: absolute;
display: none;
left: 0px;
bottom: -35px;
width: 100%;
height: 30px;
/* transition: all .5s; */
line-height: 30px;
color: #fff;
/* animation:move 1.5s linear; */
}
@keyframes move {
0% {
bottom: -35px;
}
50%,
80% {
bottom: 0px;
}
100% {
bottom: -35px;
}
}
.suc-bg {
background-color: rgba(92, 184, 92, 0.5);
filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7f5CB85C, endcolorstr=#7f5CB85C);
}
.err-bg {
background-color: rgba(217, 83, 79, 0.5);
filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7fD9534F, endcolorstr=#7fD9534F);
}
/*滑动验证码*/
.verify-bar-area {
position: relative;
background: #ffffff;
text-align: center;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
-webkit-border-radius: 4px;
}
.verify-bar-area .verify-move-block {
position: absolute;
top: 0px;
left: 0;
background: #fff;
cursor: pointer;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
box-shadow: 0 0 2px #888888;
-webkit-border-radius: 1px;
}
.verify-bar-area .verify-move-block:hover {
background-color: #337ab7;
color: #ffffff;
}
.verify-bar-area .verify-left-bar {
position: absolute;
top: -1px;
left: -1px;
background: #f0fff0;
cursor: pointer;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
}
.verify-img-panel {
margin: 0;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
border-radius: 3px;
position: relative;
}
.verify-img-panel .verify-refresh {
width: 25px;
height: 25px;
text-align: center;
padding: 5px;
cursor: pointer;
position: absolute;
top: 0;
right: 0;
z-index: 2;
}
.verify-img-panel .icon-refresh {
font-size: 20px;
color: #fff;
}
.verify-img-panel .verify-gap {
background-color: #fff;
position: relative;
z-index: 2;
border: 1px solid #fff;
}
.verify-bar-area .verify-move-block .verify-sub-block {
position: absolute;
text-align: center;
z-index: 3;
/* border: 1px solid #fff; */
}
.verify-bar-area .verify-move-block .verify-icon {
font-size: 18px;
}
.verify-bar-area .verify-msg {
z-index: 3;
}
/* 字体图标的css */
@font-face {
font-family: "iconfont";
src: url("./assets/font/iconfont.eot?t=1508229193188"); /* IE9*/
src: url("./assets/font/iconfont.eot?t=1508229193188#iefix")
format("embedded-opentype"),
/* IE6-IE8 */
url("data:application/x-font-woff;charset=utf-8;base64,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")
format("woff"),
url("./assets/font/iconfont.ttf?t=1508229193188") format("truetype"),
/* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
url("./assets/font/iconfont.svg?t=1508229193188#iconfont") format("svg"); /* iOS 4.1- */
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-check:before {
content: "\e645";
}
.icon-close:before {
content: "\e646";
}
.icon-right:before {
content: "\e6a3";
}
.icon-refresh:before {
content: "\e6a4";
}
1.3?verifySlipping.component.ts
官网示例中,初始化验证码是在组件里进行的
但是,我的项目在 初始化验证码请求图片 时,需要传入额外的参数;因此,我注释了组件内的 初始化验证码 逻辑
文件地址:src\app\components\verify\verifySlipping.component.ts
import { Component } from '@angular/core';
import './verify/verify.js';
@Component({
selector: 'verify-slipping',
templateUrl: './verifySlipping.component.html',
styleUrls: ['./verifySlipping.component.css'],
})
export class verifySlippingComponent {
/**
* 页面初始化
*/
ngOnInit(): void {
// 引入 promise
if (!window.Promise) {
document.writeln(
'<script src="https://cdnjs.cloudflare.com/ajax/libs/es6-promise/4.1.1/es6-promise.min.js"><' +
'/' +
'script>'
);
}
// 初始化验证码
// this.getVerify();
}
/**
* 初始化验证码 - 嵌入式
*/
// getVerify() {
// (<any>$('#mpanel1')).slideVerify({
// // 请求后端的服务器地址, 默认地址为安吉服务器
// baseUrl: `${interfaceId.getSecurityKeyJWT}`,
// // 验证码类型 1)滑动拼图 blockPuzzle 2)文字点选 clickWord
// // captchaType: 'blockPuzzle',
// // 验证码的显示方式,弹出式 - pop,固定 - fixed,默认为 mode: 'pop'
// mode: 'pop',
// // 被点击之后出现行为验证码的元素id(mode 为 pop 模式时必填)
// containerId:'btn',
// // 图片的大小对象, 有默认值 { width: '310px', height: '155px' }, 可省略
// imgSize: {
// width: '400px',
// height: '200px',
// },
// // 下方滑块的大小对象, 有默认值 { width: '310px', height: '50px' }, 可省略
// barSize: {
// width: '400px',
// height: '40px',
// },
// // 验证码图片和移动条容器的间隔,单位是px,默认为 vSpace: 5
// // vSpace: 5,
// // 滑动条内的提示,不设置默认是:'向右滑动完成验证'
// // explain: '向右滑动完成验证',
// // 调用验证码前,检验参数合法性的函数,(mode = 'pop' 有效)
// beforeCheck: function () {
// let flag = true;
// // 实现: 参数合法性的判断逻辑, 返回一个 boolean 值
// return flag;
// },
// // 验证码初始化成功的回调函数
// ready: function () {},
// // 验证码匹配成功后的回调函数
// success: function (params: any) {
// // params 为返回的 二次验证参数
// // 需要在下面补充逻辑 —— 把二次验证参数回传服务器【例如: login($.extend({}, params))】
// console.log('验证码匹配成功后的回调函数 params ====', params);
// },
// // 验证码匹配失败后的回调函数
// error: function () {},
// });
// }
}
1.4 verify.js
这是验证码核心逻辑文件了,我直接保留了官网 Angular 示例中的源文件
值得注意的一段逻辑:
- 通过 jq 控制验证码弹框的显隐,实现交互
- 验证码只需要初始化一次,页面中就会被动态写入 .mask DOM 元素,如果多次注册,会导致页面中有多个 .mask DOM元素
- 因此我们只初始化一次,后面通过 jq 控制 验证码相关的 DOM 显隐即可
if (this.options.mode == 'pop') {
_this.$element.find('.verifybox-close').on('click', function () {
_this.$element.find(".mask").css("display", "none");
});
let clickBtn = document.getElementById(this.options.containerId);
clickBtn && (clickBtn.onclick = function () {
if (_this.options.beforeCheck()) {
_this.$element.find(".mask").css("display", "block");
}
})
}
?
?下面这段是说:点击某按钮后,会执行滑动校验方法,进行验证码弹框初始化
// 在插件中使用 slideVerify 对象
$.fn.slideVerify = function (options, callbacks) {
var slide = new Slide(this, options);
if (slide.options.mode == "pop") {
slide.init();
} else if (slide.options.mode == "fixed") {
slide.init();
}
};
?
全部代码如下,如果放在 Angular 项目里,强烈建议把这些换成 JavaScript 写,避免使用 jquery,由于我的时间有限,所以就暂时不改了
文件地址:src\app\components\verify\verify\verify.js
import * as jQuery from 'jquery';
import aesEncrypt from "./ase.js";
/*! Verify&admin MIT License by anji-plus*/
(function ($, window, document, undefined) {
//请求图片get事件
function getPictrue(data, baseUrl) {
return new Promise((resolve, reject) => {
$.ajax({
type: "post",
contentType: "application/json;charset=UTF-8",
url: baseUrl + "/captcha/get",
dataType: 'json',
data: JSON.stringify(data),
success: function (res) {
resolve(res)
},
fail: function (err) {
reject(err)
}
})
})
}
//验证图片check事件
function checkPictrue(data, baseUrl) {
return new Promise((resolve, reject) => {
$.ajax({
type: "post",
contentType: "application/json;charset=UTF-8",
url: baseUrl + "/captcha/check",
dataType: 'json',
data: JSON.stringify(data),
success: function (res) {
if (res.repCode == "0000") {
resolve(res)
setTimeout(function () {
$('#click').hide(true)
$('#slipping').hide(true)
$(".modal-backdrop").css('display', 'none');
}, 1000);
}
resolve(res)
},
fail: function (err) {
reject(err)
}
})
})
}
//定义Slide的构造函数
var Slide = function (ele, opt) {
this.$element = ele,
this.backToken = null,
this.moveLeftDistance = 0,
this.secretKey = '',
this.defaults = {
baseUrl: "https://captcha.anji-plus.com/captcha-api",
containerId: '',
captchaType: "blockPuzzle",
mode: 'fixed', //弹出式pop,固定fixed
vOffset: 5,
vSpace: 5,
explain: '向右滑动完成验证',
imgSize: {
width: '310px',
height: '155px',
},
blockSize: {
width: '50px',
height: '50px',
},
circleRadius: '10px',
barSize: {
width: '310px',
height: '50px',
},
beforeCheck() { return true },
ready: function () { },
success: function () { },
error: function () { }
},
this.options = $.extend({}, this.defaults, opt)
};
//定义Slide的方法
Slide.prototype = {
init: function () {
var _this = this;
//加载页面
this.loadDom();
_this.refresh();
this.options.ready();
this.$element[0].onselectstart = document.body.ondrag = function () {
return false;
};
if (this.options.mode == 'pop') {
_this.$element.find('.verifybox-close').on('click', function () {
_this.$element.find(".mask").css("display", "none");
_this.refresh();
});
let clickBtn = document.getElementById(this.options.containerId);
clickBtn && (clickBtn.onclick = function () {
if (_this.options.beforeCheck()) {
// debugger
_this.$element.find(".mask").css("display", "block");
}
})
}
//按下
this.htmlDoms.move_block.on('touchstart', function (e) {
_this.start(e);
});
this.htmlDoms.move_block.on('mousedown', function (e) {
_this.start(e);
});
//拖动
window.addEventListener("touchmove", function (e) {
_this.move(e);
});
window.addEventListener("mousemove", function (e) {
_this.move(e);
});
//鼠标松开
window.addEventListener("touchend", function () {
_this.end();
});
window.addEventListener("mouseup", function () {
_this.end();
});
//刷新
_this.$element.find('.verify-refresh').on('click', function () {
_this.refresh();
});
},
//初始化加载
loadDom: function () {
this.status = false; //鼠标状态
this.isEnd = false; //是够验证完成
this.setSize = this.resetSize(this); //重新设置宽度高度
this.plusWidth = 0;
this.plusHeight = 0;
this.x = 0;
this.y = 0;
var panelHtml = '';
var wrapHtml = '';
var wrapStartHtml = '';
var wrapEndHtml = '';
this.lengthPercent = (parseInt(this.setSize.img_width) - parseInt(this.setSize.block_width) - parseInt(this.setSize.circle_radius) - parseInt(this.setSize.circle_radius) * 0.8) / (parseInt(this.setSize.img_width) - parseInt(this.setSize.bar_height));
wrapStartHtml = `<div class="mask">
<div class="verifybox" style="width:${parseInt(this.setSize.img_width) + 30}px">
<div class="verifybox-top">
请完成安全验证
<span class="verifybox-close">
<i class="iconfont icon-close"></i>
</span>
</div>
<div class="verifybox-bottom" style="padding:15px">
<div style="position: relative;">`
if (this.options.mode == 'pop') {
panelHtml = wrapStartHtml
}
panelHtml += `<div class="verify-img-out">
<div class="verify-img-panel">
<div class="verify-refresh" style="z-index:3">
<i class="iconfont icon-refresh"></i>
</div>
<span class="verify-tips" class="suc-bg"></span>
<img src="" class="backImg" style="width:100%;height:100%;display:block">
</div>
</div>`;
this.plusWidth = parseInt(this.setSize.block_width) + parseInt(this.setSize.circle_radius) * 2 - parseInt(this.setSize.circle_radius) * 0.2;
this.plusHeight = parseInt(this.setSize.block_height) + parseInt(this.setSize.circle_radius) * 2 - parseInt(this.setSize.circle_radius) * 0.2;
panelHtml += `<div class="verify-bar-area" style="{width:${this.setSize.img_width},height:${this.setSize.bar_height},'line-height':${this.setSize.bar_height}">
<span class="verify-msg">${this.options.explain}</span>
<div class="verify-left-bar">
<span class="verify-msg"></span>
<div class="verify-move-block">
<i class="verify-icon iconfont icon-right"></i>
<div class="verify-sub-block">
<img src="" class="bock-backImg" alt="" style="width:100%;height:100%;display:block">
</div>
</div>
</div>
</div>`;
wrapEndHtml = `</div></div></div></div>`
if (this.options.mode == 'pop') {
panelHtml += wrapEndHtml
}
this.$element.append(panelHtml);
this.htmlDoms = {
tips: this.$element.find('.verify-tips'),
sub_block: this.$element.find('.verify-sub-block'),
out_panel: this.$element.find('.verify-img-out'),
img_panel: this.$element.find('.verify-img-panel'),
img_canvas: this.$element.find('.verify-img-canvas'),
bar_area: this.$element.find('.verify-bar-area'),
move_block: this.$element.find('.verify-move-block'),
left_bar: this.$element.find('.verify-left-bar'),
msg: this.$element.find('.verify-msg'),
icon: this.$element.find('.verify-icon'),
refresh: this.$element.find('.verify-refresh')
};
this.$element.css('position', 'relative');
this.htmlDoms.sub_block.css({
'height': this.setSize.img_height, 'width': Math.floor(parseInt(this.setSize.img_width) * 47 / 310) + 'px',
'top': -(parseInt(this.setSize.img_height) + this.options.vSpace) + 'px'
})
this.htmlDoms.out_panel.css('height', parseInt(this.setSize.img_height) + this.options.vSpace + 'px');
this.htmlDoms.img_panel.css({ 'width': this.setSize.img_width, 'height': this.setSize.img_height });
this.htmlDoms.bar_area.css({ 'width': this.setSize.img_width, 'height': this.setSize.bar_height, 'line-height': this.setSize.bar_height });
this.htmlDoms.move_block.css({ 'width': this.setSize.bar_height, 'height': this.setSize.bar_height });
this.htmlDoms.left_bar.css({ 'width': this.setSize.bar_height, 'height': this.setSize.bar_height });
},
//鼠标按下
start: function (e) {
if (!e.touches) { //兼容移动端
var x = e.clientX;
} else { //兼容PC端
var x = e.touches[0].pageX;
}
this.startLeft = Math.floor(x - this.htmlDoms.bar_area[0].getBoundingClientRect().left);
this.startMoveTime = new Date().getTime();
if (this.isEnd == false) {
this.htmlDoms.msg.text('');
this.htmlDoms.move_block.css('background-color', '#337ab7');
this.htmlDoms.left_bar.css('border-color', '#337AB7');
this.htmlDoms.icon.css('color', '#fff');
e.stopPropagation();
this.status = true;
}
},
//鼠标移动
move: function (e) {
if (this.status && this.isEnd == false) {
if (!e.touches) { //兼容移动端
var x = e.clientX;
} else { //兼容PC端
var x = e.touches[0].pageX;
}
var bar_area_left = this.htmlDoms.bar_area[0].getBoundingClientRect().left;
var move_block_left = x - bar_area_left; //小方块相对于父元素的left值
if (move_block_left >= (this.htmlDoms.bar_area[0].offsetWidth - parseInt(this.setSize.bar_height) + parseInt(parseInt(this.setSize.block_width) / 2) - 2)) {
move_block_left = (this.htmlDoms.bar_area[0].offsetWidth - parseInt(this.setSize.bar_height) + parseInt(parseInt(this.setSize.block_width) / 2) - 2);
}
if (move_block_left <= parseInt(parseInt(this.setSize.block_width) / 2)) {
move_block_left = parseInt(parseInt(this.setSize.block_width) / 2);
}
//拖动后小方块的left值
this.htmlDoms.move_block.css('left', move_block_left - this.startLeft + "px");
this.htmlDoms.left_bar.css('width', move_block_left - this.startLeft + "px");
this.htmlDoms.sub_block.css('left', "0px");
this.moveLeftDistance = move_block_left - this.startLeft
}
},
//鼠标松开
end: function () {
this.endMovetime = new Date().getTime();
var _this = this;
//判断是否重合
if (this.status && this.isEnd == false) {
var vOffset = parseInt(this.options.vOffset);
this.moveLeftDistance = this.moveLeftDistance * 310 / parseInt(this.setSize.img_width)
//图片滑动
let data = {
captchaType: this.options.captchaType,
"pointJson": this.secretKey ? aesEncrypt(JSON.stringify({ x: this.moveLeftDistance, y: 5.0 }), this.secretKey) : JSON.stringify({ x: this.moveLeftDistance, y: 5.0 }),
"token": this.backToken
}
var captchaVerification = this.secretKey ? aesEncrypt(this.backToken + '---' + JSON.stringify({ x: this.moveLeftDistance, y: 5.0 }), this.secretKey) : this.backToken + '---' + JSON.stringify({ x: this.moveLeftDistance, y: 5.0 })
checkPictrue(data, this.options.baseUrl).then(res => {
// 请求反正成功的判断
if (res.repCode == "0000") {
this.htmlDoms.move_block.css('background-color', '#5cb85c');
this.htmlDoms.left_bar.css({ 'border-color': '#5cb85c', 'background-color': '#fff' });
this.htmlDoms.icon.css('color', '#fff');
this.htmlDoms.icon.removeClass('icon-right');
this.htmlDoms.icon.addClass('icon-check');
//提示框
this.htmlDoms.tips.addClass('suc-bg').removeClass('err-bg')
this.htmlDoms.tips.css({ "display": "block", animation: "move 1s cubic-bezier(0, 0, 0.39, 1.01)" });
this.htmlDoms.tips.text(`${((this.endMovetime - this.startMoveTime) / 1000).toFixed(2)}s验证成功`)
this.isEnd = true;
setTimeout(res => {
_this.$element.find(".mask").css("display", "none");
this.htmlDoms.tips.css({ "display": "none", animation: "none" });
_this.refresh();
}, 1000)
this.options.success({ captchaVerification });
} else {
this.htmlDoms.move_block.css('background-color', '#d9534f');
this.htmlDoms.left_bar.css('border-color', '#d9534f');
this.htmlDoms.icon.css('color', '#fff');
this.htmlDoms.icon.removeClass('icon-right');
this.htmlDoms.icon.addClass('icon-close');
this.htmlDoms.tips.addClass('err-bg').removeClass('suc-bg')
this.htmlDoms.tips.css({ "display": "block", animation: "move 1.3s cubic-bezier(0, 0, 0.39, 1.01)" });
this.htmlDoms.tips.text(res.repMsg)
setTimeout(function () {
_this.refresh();
}, 400);
setTimeout(() => {
this.htmlDoms.tips.css({ "display": "none", animation: "none" });
}, 1300)
this.options.error(this);
}
})
this.status = false;
}
},
resetSize: function (obj) {
var img_width, img_height, bar_width, bar_height, block_width, block_height, circle_radius; //图片的宽度、高度,移动条的宽度、高度
var parentWidth = obj.$element.parent().width() || $(window).width();
var parentHeight = obj.$element.parent().height() || $(window).height();
if (obj.options.imgSize.width.indexOf('%') != -1) {
img_width = parseInt(obj.options.imgSize.width) / 100 * parentWidth + 'px';
} else {
img_width = obj.options.imgSize.width;
}
if (obj.options.imgSize.height.indexOf('%') != -1) {
img_height = parseInt(obj.options.imgSize.height) / 100 * parentHeight + 'px';
} else {
img_height = obj.options.imgSize.height;
}
if (obj.options.barSize.width.indexOf('%') != -1) {
bar_width = parseInt(obj.options.barSize.width) / 100 * parentWidth + 'px';
} else {
bar_width = obj.options.barSize.width;
}
if (obj.options.barSize.height.indexOf('%') != -1) {
bar_height = parseInt(obj.options.barSize.height) / 100 * parentHeight + 'px';
} else {
bar_height = obj.options.barSize.height;
}
if (obj.options.blockSize) {
if (obj.options.blockSize.width.indexOf('%') != -1) {
block_width = parseInt(obj.options.blockSize.width) / 100 * parentWidth + 'px';
} else {
block_width = obj.options.blockSize.width;
}
if (obj.options.blockSize.height.indexOf('%') != -1) {
block_height = parseInt(obj.options.blockSize.height) / 100 * parentHeight + 'px';
} else {
block_height = obj.options.blockSize.height;
}
}
if (obj.options.circleRadius) {
if (obj.options.circleRadius.indexOf('%') != -1) {
circle_radius = parseInt(obj.options.circleRadius) / 100 * parentHeight + 'px';
} else {
circle_radius = obj.options.circleRadius;
}
}
return { img_width: img_width, img_height: img_height, bar_width: bar_width, bar_height: bar_height, block_width: block_width, block_height: block_height, circle_radius: circle_radius };
},
//刷新
refresh: function () {
var _this = this;
this.htmlDoms.refresh.show();
this.$element.find('.verify-msg:eq(1)').text('');
this.$element.find('.verify-msg:eq(1)').css('color', '#000');
this.htmlDoms.move_block.animate({ 'left': '0px' }, 'fast');
this.htmlDoms.left_bar.animate({ 'width': parseInt(this.setSize.bar_height) }, 'fast');
this.htmlDoms.left_bar.css({ 'border-color': '#ddd' });
this.htmlDoms.move_block.css('background-color', '#fff');
this.htmlDoms.icon.css('color', '#000');
this.htmlDoms.icon.removeClass('icon-close');
this.htmlDoms.icon.addClass('icon-right');
this.$element.find('.verify-msg:eq(0)').text(this.options.explain);
this.isEnd = false;
getPictrue({ captchaType: "blockPuzzle" }, this.options.baseUrl).then(res => {
if (res.repCode == "0000") {
this.$element.find(".backImg")[0].src = 'data:image/png;base64,' + res.repData.originalImageBase64
this.$element.find(".bock-backImg")[0].src = 'data:image/png;base64,' + res.repData.jigsawImageBase64
this.secretKey = res.repData.secretKey
this.backToken = res.repData.token
}
});
this.htmlDoms.sub_block.css('left', "0px");
},
};
//定义Points的构造函数
var Points = function (ele, opt) {
this.$element = ele,
this.backToken = null,
this.secretKey = '',
this.defaults = {
baseUrl: "https://captcha.anji-plus.com/captcha-api",
captchaType: "clickWord",
containerId: '',
mode: 'fixed', //弹出式pop,固定fixed
checkNum: 3, //校对的文字数量
vSpace: 5, //间隔
imgSize: {
width: '310px',
height: '155px',
},
barSize: {
width: '310px',
height: '50px',
},
beforeCheck() { return true },
ready: function () { },
success: function () { },
error: function () { }
},
this.options = $.extend({}, this.defaults, opt)
};
//定义Points的方法
Points.prototype = {
init: function () {
var _this = this;
//加载页面
_this.loadDom();
_this.refresh();
_this.options.ready();
this.$element[0].onselectstart = document.body.ondrag = function () {
return false;
};
if (this.options.mode == 'pop') {
_this.$element.find('.verifybox-close').on('click', function () {
_this.$element.find(".mask").css("display", "none");
});
let clickBtn = document.getElementById(this.options.containerId);
clickBtn && (clickBtn.onclick = function () {
if (_this.options.beforeCheck()) {
_this.$element.find(".mask").css("display", "block");
}
})
}
// 注册点击验证事件
_this.$element.find('.back-img').on('click', function (e) {
_this.checkPosArr.push(_this.getMousePos(this, e));
if (_this.num == _this.options.checkNum) {
_this.num = _this.createPoint(_this.getMousePos(this, e));
//按比例转换坐标值
_this.checkPosArr = _this.pointTransfrom(_this.checkPosArr, _this.setSize);
setTimeout(() => {
let data = {
captchaType: _this.options.captchaType,
"pointJson": _this.secretKey ? aesEncrypt(JSON.stringify(_this.checkPosArr), _this.secretKey) : JSON.stringify(_this.checkPosArr),
"token": _this.backToken
}
var captchaVerification = _this.secretKey ? aesEncrypt(_this.backToken + '---' + JSON.stringify(_this.checkPosArr), _this.secretKey) : _this.backToken + '---' + JSON.stringify(_this.checkPosArr)
checkPictrue(data, _this.options.baseUrl).then(res => {
if (res.repCode == "0000") {
_this.$element.find('.verify-bar-area').css({ 'color': '#4cae4c', 'border-color': '#5cb85c' });
_this.$element.find('.verify-msg').text('验证成功');
// _this.$element.find('.verify-refresh').hide();
_this.$element.find('.verify-img-panel').unbind('click');
setTimeout(res => {
_this.$element.find(".mask").css("display", "none");
_this.refresh();
}, 1000)
_this.options.success({ captchaVerification });
} else {
_this.options.error(_this);
_this.$element.find('.verify-bar-area').css({ 'color': '#d9534f', 'border-color': '#d9534f' });
_this.$element.find('.verify-msg').text('验证失败');
setTimeout(function () {
_this.$element.find('.verify-bar-area').css({ 'color': '#000', 'border-color': '#ddd' });
_this.refresh();
}, 400);
}
})
}, 400);
}
if (_this.num < _this.options.checkNum) {
_this.num = _this.createPoint(_this.getMousePos(this, e));
}
});
//刷新
_this.$element.find('.verify-refresh').on('click', function () {
_this.refresh();
});
},
//加载页面
loadDom: function () {
this.fontPos = []; //选中的坐标信息
this.checkPosArr = []; //用户点击的坐标
this.num = 1; //点击的记数
var panelHtml = '';
var wrapStartHtml = '';
var wrapEndHtml = '';
this.setSize = Slide.prototype.resetSize(this); //重新设置宽度高度
wrapStartHtml = `<div class="mask">
<div class="verifybox" style="width:${parseInt(this.setSize.img_width) + 30}px">
<div class="verifybox-top">
请完成安全验证
<span class="verifybox-close">
<i class="iconfont icon-close"></i>
</span>
</div>
<div class="verifybox-bottom" style="padding:15px">
<div style="position: relative;">`
if (this.options.mode == 'pop') {
panelHtml = wrapStartHtml
}
panelHtml += `<div class="verify-img-out">
<div class="verify-img-panel">
<div class="verify-refresh" style="z-index:3">
<i class="iconfont icon-refresh"></i>
</div>
<img src='' class="back-img" width="${this.setSize.img_width}" height="${this.setSize.img_height}">
</div>
</div>
<div class="verify-bar-area" style="{width:${this.setSize.img_width},height:${this.setSize.bar_height},'line-height':${this.setSize.bar_height}">
<span class="verify-msg"></span>
</div>`;
wrapEndHtml = `</div></div></div></div>`
if (this.options.mode == 'pop') {
panelHtml += wrapEndHtml
}
this.$element.append(panelHtml);
this.htmlDoms = {
back_img: this.$element.find('.back-img'),
out_panel: this.$element.find('.verify-img-out'),
img_panel: this.$element.find('.verify-img-panel'),
bar_area: this.$element.find('.verify-bar-area'),
msg: this.$element.find('.verify-msg'),
};
this.$element.css('position', 'relative');
this.htmlDoms.out_panel.css('height', parseInt(this.setSize.img_height) + this.options.vSpace + 'px');
this.htmlDoms.img_panel.css({ 'width': this.setSize.img_width, 'height': this.setSize.img_height, 'background-size': this.setSize.img_width + ' ' + this.setSize.img_height, 'margin-bottom': this.options.vSpace + 'px' });
this.htmlDoms.bar_area.css({ 'width': this.setSize.img_width, 'height': this.setSize.bar_height, 'line-height': this.setSize.bar_height });
},
//获取坐标
getMousePos: function (obj, event) {
var e = event || window.event;
var scrollX = document.documentElement.scrollLeft || document.body.scrollLeft;
var scrollY = document.documentElement.scrollTop || document.body.scrollTop;
var x = e.clientX - ($(obj).offset().left - $(window).scrollLeft());
var y = e.clientY - ($(obj).offset().top - $(window).scrollTop());
return { 'x': x, 'y': y };
},
//创建坐标点
createPoint: function (pos) {
this.htmlDoms.img_panel.append(`<div class="point-area" style="background-color:#1abd6c;color:#fff;z-index:9999;width:20px;height:20px;text-align:center;line-height:20px;border-radius: 50%;position:absolute;
top:${parseInt(pos.y - 10)}px;left:${parseInt(pos.x - 10)}px;">${this.num}</div>`);
return ++this.num;
},
//刷新
refresh: function () {
var _this = this;
this.$element.find('.point-area').remove();
this.fontPos = [];
this.checkPosArr = [];
this.num = 1;
getPictrue({ captchaType: "clickWord" }, _this.options.baseUrl).then(res => {
if (res.repCode == "0000") {
this.htmlDoms.back_img[0].src = 'data:image/png;base64,' + res.repData.originalImageBase64
this.backToken = res.repData.token
this.secretKey = res.repData.secretKey
let text = '请依次点击【' + res.repData.wordList.join(",") + '】'
_this.$element.find('.verify-msg').text(text);
}
})
},
pointTransfrom: function (pointArr, imgSize) {
var newPointArr = pointArr.map(p => {
let x = Math.round(310 * p.x / parseInt(imgSize.img_width))
let y = Math.round(155 * p.y / parseInt(imgSize.img_height))
return { x, y }
})
return newPointArr
}
};
//在插件中使用slideVerify对象
$.fn.slideVerify = function (options, callbacks) {
var slide = new Slide(this, options);
if (slide.options.mode == "pop") {
slide.init();
} else if (slide.options.mode == "fixed") {
slide.init();
}
};
//在插件中使用clickVerify对象
$.fn.pointsVerify = function (options, callbacks) {
var points = new Points(this, options);
if (points.options.mode == "pop") {
points.init();
} else if (points.options.mode == "fixed") {
points.init();
}
};
})(jQuery, window, document);
?
1.5?aes-encrypt-decrypt.ts
AES 加密方法
需要注意 密钥、偏移量 都是可以动态传入的哦
文件地址:src\app\components\verify\verify\ase-encrypt-decrypt.ts
import { enc, mode, AES, pad } from 'crypto-js';
// 加密密钥(长度必须是 16 的整数倍,此处为 32 位)
const secretKey = '5405xxxx778e38acbxxxxxxx002fb4ce';
// 偏移量
const iv = 'xxxxxxxx';
/**
* AES加密
* @description 使用加密秘钥,对 需要加密的参数 进行加密
* @param {string} word - 需要加密的参数
* @param {string} key - 加密密钥(长度必须是 16 的整数倍)
* @param {string} offset - 偏移量
*/
export function aseEncryptParams(word: any, key = secretKey, offset = iv) {
// 未加密的参数 - 从 UTF-8编码 解析出原始字符串
const wordUTF8 = enc.Utf8.parse(word);
// 密钥 - 从 UTF-8编码 解析出原始字符串
const keyUTF8 = enc.Utf8.parse(key);
// 偏移量(在此公司内是固定的) - 从 UTF-8编码 解析出原始字符串
const offsetUTF8 = enc.Utf8.parse(offset);
// 补充
// 把字符串转成 UTF-8编码 —— enc.Utf8.stringify(word);
const encrypted = AES.encrypt(wordUTF8, keyUTF8, {
iv: offsetUTF8,
mode: mode.CBC,
padding: pad.Pkcs7,
});
return encrypted.toString();
}
/**
* AES解密
* @description 使用加密秘钥,对 需要解密的参数 进行解密
* @param {string} encryptedWord - 需要解密的参数
* @param {string} key - 加密密钥(长度必须是 16 的整数倍)
* @param {string} offset - 偏移量
*/
export function aesDecryptParams(
encryptedWord: any,
key = secretKey,
offset = iv
) {
// 密钥 - 从 UTF-8编码 解析出原始字符串
const keyUTF8 = enc.Utf8.parse(key);
// 偏移量(在此公司内是固定的) - 从 UTF-8编码 解析出原始字符串
const offsetUTF8 = enc.Utf8.parse(offset);
const bytes = AES.decrypt(encryptedWord, keyUTF8, {
iv: offsetUTF8,
mode: mode.CBC,
padding: pad.Pkcs7,
});
return bytes.toString(enc.Utf8);
}
1.6?verify.service.ts
这是验证码校验相关的接口逻辑,基本 cv 了官网 Angular 示例项目
包含了两个方法:
修改的地方:把默认的接口地址,改成了公司后台给的验证码校验接口地址
文件地址:src\app\service\verify.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { interfaceId } from '../types/interfaceId';
@Injectable()
export class VerifyService {
// 设置验证地址
private VerifyesUrl = `${interfaceId.getSecurityKeyJWT}`;
constructor(private http: HttpClient) {}
/**
* 请求图片 - get事件
* @returns
*/
getVerify(params: any): Promise<any> {
return this.http
.post(this.VerifyesUrl + '/captcha/get', params, {
responseType: 'json',
})
.toPromise()
.then((response: any) => response)
.catch(this.handleError);
}
/**
* 验证图片 - check事件
* @returns
*/
getVerifyCheck(params: any): Promise<any> {
return (
this.http
.post(this.VerifyesUrl + '/captcha/check', params, {
responseType: 'json',
})
.toPromise()
// .then((response: any) => response.json())
.then((response: any) => response)
.catch(this.handleError)
);
}
/**
* 异常处理
* @returns
*/
private handleError(error: any): Promise<any> {
console.error('滑动校验验证码接口请求发生错误', error);
return Promise.reject(error.message || error);
}
}
2. 添加登录时的验证码校验逻辑
2.1 保持 loginService 服务为单例,避免路由守卫出 bug
login.service.ts 登录相关的服务
需要注意以下代码,全局注入了登录服务
@Injectable({
providedIn: 'root',
})
在任何组件的 module.ts 中,都不应该重复注入 loginService 服务,这会导致 loginService 服务非单例,项目中的路由守卫就容易出现问题
可以看见,下面的代码里判断了登陆服务内的 loginOn,如果登录了,则进行跳转,否则仍然停留在登录页
private checkLogin(url: string): boolean {
// 登录成功后,修改单例模式中的登录标志,进而阻止路由守卫
if (this.loginService.loginOn) {
return true;
}
this.loginService.url = url;
this.router.navigate([ 'login' ]);
return false;
}
如果我再 login.module.ts 中,还要再依赖注入一次 loginService,就会导致整个项目中有两个 loginService 实例
那么你即使修改了一个 loginOn,另一个实例中的l oginOn 没有修改,也会导致 路由守卫持续拦截
跳转到应用正式页面时,有没有路由守卫,可以看路由文件
文件地址:src\app\app-routing.module.ts
{
path: 'tabs',
loadChildren: () => import('./tabs/tabs.module').then((m) => m.TabsPageModule),
canActivate: [ LoginGuard ],
canActivateChild: [ LoginGuard ],
},
解决这个问题踩了这两个坑,汇总下:
- 路由跳转失败,要考虑是不是路由守卫导致的,可以从路由配置文件里看
- 不要重复引入 loginService 服务,让他保持单例,防止路由守卫误判
2.2 获取密钥时,Angular Http 请求发送失败?
https://www.jianshu.com/p/64e10c705eb9?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendationhttps://www.jianshu.com/p/64e10c705eb9?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation
解决方案:添加响应类型 responseType,如下代码所示
/**
* 获取包含AES加密秘钥 securityKey 的 securityKeyJWT
*/
getSecurityKeyJWT(loginName: string) {
return this.http.get<any>(
`${interfaceId.getSecurityKeyJWT}/${loginName}/authkey`,
{
// Http failure during parsing
responseType: 'text',
} as any
);
}
2.3 点击登录按钮,初始化验证码
在?login.module.ts 文件中,需要引入滑动验证码组件、验证码服务(再次强调,不要引入登录服务,原因见 2.1)
// 验证码
import { verifySlippingComponent } from '../../components/verify/verifySlipping.component';
// 验证服务(一定不要引入登录服务,因为登陆服务已经全局注入了,为了避免产生多个实例,导致修改登录状态失败,路由守卫跳转失败的问题)
import { VerifyService } from '../../service/verify.service';
@NgModule({
declarations: [LoginComponent, LoginIframeComponent, verifySlippingComponent],
providers: [VerifyService],
})
在?login.component.html 文件中,需要引入滑动验证码组件,需要给登录按钮添加指定ID
添加 id、data-target 都是为了保持跟 Angular 官网示例一致,如果把这些ID名改了,你还要修改 verify.js 中的变量名,非常费劲,因此强烈建议与官网示例保持一致
<!-- 登录按钮 -->
<i id="btn" data-target="#slipping" class="submit" (click)="clickLoginBtn()"></i>
<!-- 滑动验证 -->
<verify-slipping></verify-slipping>
点击按钮之后,要执行的逻辑:
- 判断输入参数合法性
- 根据用户名获取包含AES加密秘钥 securityKey 的 securityKeyJWT
- 解析 securityKeyJWT,获取 aes 加密秘钥 securityKey
- 根据加密密钥,初始化验证码
- 始化完成后,使用用户名和加密后的密码,调用认证服务
需要注意全局定义一个?loadFinished ,用于标识验证码是否已经初始化过了
- 如果没初始化,则走初始化验证码的方法,并控制验证码弹窗显示(只能初始化一次)
- 如果初始化了,控制显示验证码弹框,验证码组件内部会监听到,走校验验证码并登录的方法
无论走哪一步,都需要操作 DOM,控制验证码弹窗显隐
// 初始化完成后,展示验证码弹窗
$('.mask').css('display', 'block');
// 直接展示验证码弹窗
$('.mask').css('display', 'block');
$('#slipping').css('display', 'block');
$('.modal-backdrop').css('display', 'block');
下面是点击登录按钮后,执行的方法?
/**
* 点击登录按钮
* @description 判断输入参数合法性
* @description 根据用户名获取包含AES加密秘钥 securityKey 的 securityKeyJWT
* @description 解析 securityKeyJWT,获取 aes 加密秘钥 securityKey
* @description 初始化验证码
* @description 始化完成后,使用用户名和加密后的密码,调用认证服务
*/
async clickLoginBtn() {
// 判空处理
this.controlOption.type = 'password';
this.toolsService.hiddenBottomTabs = false;
for (const i of Object.keys(this.formData.controls)) {
this.formData.controls[i].markAsDirty();
this.formData.controls[i].updateValueAndValidity();
}
// 如果表单符合规则
if (this.formData.valid) {
// console.log('this.formData.value ===', this.formData.value)
// 根据用户名获取包含AES加密秘钥 securityKey 的 securityKeyJWT
this.loginService
.getSecurityKeyJWT(this.formData.value.loginName)
.subscribe(async (res) => {
// console.log(
// '根据用户名获取包含AES加密秘钥 securityKey 的 securityKeyJWT ===',
// res
// );
// 解析 securityKeyJWT,获取 aes 加密秘钥 securityKey
const { securityKey } = jwtDecode(res as any) as any;
// console.log(
// '解析 securityKeyJWT,获取 aes 加密秘钥 securityKey ===',
// securityKey
// );
// 如果没有初始化验证码,则先初始化
if (!this.loadFinished) {
console.log('初始化验证码');
// 初始化验证码
await this.initVerifyCodeImage(securityKey, res);
// 初始化完成后,展示验证码弹窗
$('.mask').css('display', 'block');
// 验证码是否加载成功
this.loadFinished = true;
} else {
console.log('已经初始化过验证码了,直接认证');
// 直接展示验证码弹窗
$('.mask').css('display', 'block');
$('#slipping').css('display', 'block');
$('.modal-backdrop').css('display', 'block');
}
});
}
}
2.4 初始化验证码
初始化验证码是官网提供的方法,里面存放了很多配置
由于 个别配置 是个回调函数,需要调用当前文件的一些方法,this 指向会存在问题,因此方法内第一行代码就是存储下 this
beforeCheck:会对登录参数进行校验,因为我再点击按钮的时候就已经校验了,因此此处直接 return true
验证码校验成功后,接口会返回一个 params 里面包含接口 二次验证 需要的参数,因此,我把这个参数存到全局里了,也就是?that.paramsFirstSuccess
注意:我尝试过使用 JavaScript 直接获取?#mpanel1,但是这会导致无法调用?slideVerify 方法初始化验证码,因此我还是继续采用 jquery 获取 DOM 元素
/**
* 初始化验证码
* @description 初始化完成后,使用用户名和加密后的密码,调用认证服务
* @param securityKey aes 加密秘钥 securityKey
* @param securityKeyJWT 根据用户名获取包含AES加密秘钥 securityKey 的 securityKeyJWT
*/
initVerifyCodeImage(securityKey, securityKeyJWT) {
const that: any = this;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(<any>$('#mpanel1')).slideVerify({
// 请求后端的服务器地址, 默认地址为安吉服务器
baseUrl: `${interfaceId.getSecurityKeyJWT}`,
// 验证码的显示方式,弹出式 - pop,固定 - fixed,默认为 mode: 'pop'
mode: 'pop',
// 被点击之后出现行为验证码的元素id(mode 为 pop 模式时必填)
containerId: 'btn',
// 图片的大小对象, 有默认值 { width: '310px', height: '155px' }, 可省略
imgSize: {
width: '400px',
height: '200px',
},
// 下方滑块的大小对象, 有默认值 { width: '310px', height: '50px' }, 可省略
barSize: {
width: '400px',
height: '40px',
},
beforeCheck: () => {
return true;
},
// 验证码匹配成功后的回调函数
success: (params: any) => {
// console.log('that', that)
// console.log('验证码匹配成功后的回调函数 params ====', params);
// 首次 验证码匹配成功后的回调函数 params
that.paramsFirstSuccess = params;
// 使用用户名和加密后的密码,调用认证服务
that.handleAuth(securityKey, securityKeyJWT);
},
// 验证码匹配失败后的回调函数
error: () => {
console.log('验证码匹配失败后的回调函数');
},
});
}
?
2.5?验证码校验成功后 执行真正的登录
真正的登录,请求地址是拼接出来的,包含了:
- 登录名
- 加密密码
- 密钥
- 验证码成功后接口返回的二次校验参数
/**
* 使用用户名和加密后的密码,调用认证服务
* @description 此处执行了真正的登录
* @param securityKey aes 加密秘钥 securityKey
* @param securityKeyJWT 根据用户名获取包含AES加密秘钥 securityKey 的 securityKeyJWT
*/
async handleAuth(securityKey, securityKeyJWT) {
// 使用 ASE加密秘钥 对 用户密码 进行加密
const encryptionPassword = aseEncryptParams(
this.formData.value.password,
securityKey
);
// eslint-disable-next-line max-len
const url = `${interfaceId....cation}`;
this.loginService
.loginLast(
url,
this.formData.value,
this.controlOption.remember,
this.controlOption.autoLogin
)
.subscribe((res) => {
console.log('使用用户名和加密后的密码,调用认证服务 ===', res);
this.loginFinal(res);
});
}
?
真正的登录接口,在返回响应之前,需要进行一些额外的操作:
- 权限验证,根据用户信息,决定跳转的路由,路由存在 loginService 里
- 开启极光推送服务
- 修改全局登录状态
- 存储用户信息、记住密码、自动登录
可以通过管道 pipe 处理响应,处理完了在返回前台
注意此处,指定接口返回类型 json(后端要求的)
loginLast(
url: string,
data: { loginName: string; password: string },
remember: boolean,
autoLogin: boolean
): any {
return this.http
.get(url, {
responseType: 'json',
})
.pipe(
tap(async (res: any) => {
if (res.message === '登录成功') {
// 权限验证,根据用户信息,决定跳转的路由,路由存在 loginService 里
// 开启极光推送服务
this.jPushAddEventListener(res.user.userId);
// 修改全局登录状态
this.loginOn = true;
// 存储用户信息、记住密码、自动登录
}),
map((res: any) => res)
);
}
登录成功后,根据 pipe 处理过的接口响应信息,进行跳转
this.router.navigate([this.loginService.url]);
2.6 效果展示
输入用户名密码,校验验证码
验证码校验成功后,正儿八经登录,验证成功后,根据不同用户跳转到不同首页
?
二. 修改密码功能
1. 进入页面后,加密登录名,获取新密钥
/**
* @description:通过加密登录名,获取的新密钥
* @return
*/
async getNewSecurityKeyByLoginName() {
// 如果没有登录名 或者没有 用户ID,则无法修改密码
if (!this.formData.value.loginName || !this.formData.value.userId) {
// 判断是否可以修改密码
this.canEditPwd = false;
this.toolSerive.presentToastWithOptions(
'缺少登录名或者用户ID,无法修改密码'
);
return;
}
// 加密登录名
const aesLoginName = aseEncryptParams(
this.formData.value.loginName,
this.aesLoginNameKey
);
// 根据加密的登录名,获取密钥
await this.loginService
.getChangePwdKey(aesLoginName)
.subscribe((res: any) => {
console.log('根据加密的登录名,获取密钥 ===', res);
// 通过加密登录名,获取的新密钥
this.securityKey = res.securityKey;
});
}
2. 校验密码,要求包含大小写字母、数字、特殊字符
/**
* 正则匹配,检查密码复杂度
* @param s 密码
* @returns
*/
checkPass(s) {
let ls = 0;
// 密码长度
if (s.length < 8) {
return 0;
}
// 包含数字
if (s.match(/([0-9])+/)) {
ls++;
}
// 包含小写字母
if (s.match(/([a-z])+/)) {
ls++;
}
// 包含大写字母
if (s.match(/([A-Z])+/)) {
ls++;
}
// 包含数字/小写字母/大写字母
if (s.match(/[^a-zA-Z0-9]+/)) {
ls++;
}
return ls;
}
/**
* 根据正则匹配结果,确定密码不合格的提示信息
*/
passwordLevel() {
// 不符合规则的密码,返回什么提示
let message = '';
// 用户新密码的安全等级
const userNewPwdLevel = this.checkPass(
this.formData.get('repeatPassword').value
);
// 系统要求的密码安全等级
const requirePwdLevel = 4;
// 如果 用户新密码的安全等级,小于系统要求的安全等级,则根据情况进行提示
if (userNewPwdLevel < requirePwdLevel) {
switch (userNewPwdLevel) {
case 0:
message = '密码长度不得少于8位';
break;
case 1:
case 2:
case 3:
case 4:
message =
'密码需要包含数字、小写字母、大写字母、特殊字符';
break;
default:
break;
}
return message;
}
return message;
}
页面中定义了这些参数
const data = {
// 通过加密登录名,获取的新密钥(使用它加密新旧密码)
securityKey: this.securityKey,
// 用户ID
USER_ID: this.formData.get('userId').value,
// 登录名
LOGIN_NAME: this.formData.get('loginName').value,
// 原密码(使用新密钥加密的)
PASSWORD_ENCRYPT: aseEncryptParams(
this.formData.get('originPassword').value,
this.securityKey
),
// 新密码(使用新密钥加密的)
NEW_PASSWORD_ENCRYPT: aseEncryptParams(
this.formData.get('repeatPassword').value,
this.securityKey
),
};
接口这么定义
/**
* 保存修改的密码
*/
savePwd(data: any) {
const form = new FormData();
// form.append('securityKey', data.securityKey);
// form.append('USER_ID', data.USER_ID);
// form.append('LOGIN_NAME', data.LOGIN_NAME);
// form.append('PASSWORD_ENCRYPT', data.PASSWORD_ENCRYPT);
// form.append('NEW_PASSWORD_ENCRYPT', data.NEW_PASSWORD_ENCRYPT);
form.append('form[\'securityKey\']', data.securityKey);
form.append('form[\'USER_ID\']', data.USER_ID);
form.append('form[\'LOGIN_NAME\']', data.LOGIN_NAME);
form.append('form[\'PASSWORD_ENCRYPT\']', data.PASSWORD_ENCRYPT);
form.append('form[\'NEW_PASSWORD_ENCRYPT\']', data.NEW_PASSWORD_ENCRYPT);
return this.http.post<Meta>(`${interfaceId.savePwdUrl}`, form);
}
最终效果
|