总结在Web开发中跟认证相关的流程,该案例使用Node.js托管前端页面,实现到后端服务器的路由跳转,称为UI Service;业务相关的请求放在Query Service中;认证相关的请求(比如登录、登出、更改密码、Token认证等)放在Auth Service中。
认证相关的数据库设计
account表
create table account
(
id char(32) not null,
account_access_info_id char(32),
company_id char(32),
firstname varchar(32) not null,
lastname varchar(32) not null,
email varchar(32) not null,
phone_number varchar(32),
title varchar(32),
password varchar(200),
salt varchar(200),
reset_password_token varchar(200),
reset_password_token_expire timestamp,
is_active bool not null,
last_update_time timestamp,
update_count int,
primary key (id)
);
account_access_info表
create table account_access_info
(
id char(32) not null,
account_id char(32),
login_token varchar(200),
login_token_expire timestamp,
attempts int,
last_attempts_time timestamp,
is_locked bool not null,
primary key (id)
);
登录认证
前端认证
登录过程首先要前端做一遍认证,用于拦截掉普通用户输入错误的情况,比如邮箱格式,密码格式(8-15位并且必须有大小写字母和数字)
- 初始状态,登录按钮处于disable状态,鼠标无法点击,即无法提交POST请求
- 用户输入帐号,输入完毕后通过TAB键或ENTER键(利用
onKeyDown
事件监听enter按钮)切换光标到密码输入框;同时检测用户输入的帐号是否符合邮箱格式,如不符合,提示Please input a valid email address
- 用户输入密码,一旦用户输入了第一位密码同时邮箱格式正确,令LOGIN按钮enable。当用户输入完毕,通过点击LOGIN按钮或按下Enter(利用onKeyDown事件监听enter按钮)来提交POST请求。POST请求的格式可以做如下参考:
// 在发送POST请求的时候给页面填加遮罩,防止用户有其他操作
$('#page_loading').removeClass('hidden');
$.ajax({
url: path.LOGIN,
method: 'POST',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({
username: _state.userName,
password: _state.password,
}),
success: function (response) {
// 请求结束,去掉遮罩
$('#page_loading').addClass('hidden');
// 保存token等信息
let responseJSON;
if (typeof response === 'string') {
responseJSON = JSON.parse(response);
} else {
responseJSON = response;
}
if (responseJSON.token.length > 0) {
sessionStorage.__ecs_user_token = responseJSON.token; // 保存token
}
// 页面跳转
location.href = `${path.MAIN_PAGE}?token=${responseJSON.token}`;
},
error: function(jqXHR, textStatus) {
// 请求结束,去掉遮罩
$('#page_loading').addClass('hidden');
...
}
})
待登录请求成功后,前端使用location.href
跳转到首页,请求中要携带token
后端的登录认证
格式验证
虽然前端已经做过一次帐号、密码的验证,但服务端必须做再次的确认,因为前端页面验证不能防范其他人恶意的尝试密码。记住:对所有到达后端的请求都要保持怀疑的态度。
if (!Validators.validateEmailAddress(loginRequestModel.getUsername()) || !Validators.validatePassword(loginRequestModel.getPassword())) {
logger.info("/login: Invalid username or password");
map.put("success", false);
map.put("message", "Invalid username or password");
return new ResponseEntity
数据库验证
- 验证用户输入的
username
是否在account表中 - 通过
id
查看account_access_info表中该用户帐号是否被锁住(isLocked
字段) - 通过
PasswordUtil.checkPassword
工具验证密码,参数为account表中的salt
,password
和用户输入的密码 - 如果输入错误,将account_access_info表中的
attempts
字段加1,并提示剩余的尝试次数;如果5次输入错误,提示账户被锁,需要联系管理员解锁密码 - 通过
JwtUtil.generateToken
工具生成token,payload
中的字段依业务逻辑而定,可以添加iat
,exp
,accountType
等信息 - 用户认证成功,生成http响应体,参考如下
@AllArgsConstructor
@NoArgsConstructor
public @Data class LoginResponseModel {
private Boolean success;
private String message;
private String token;
private String firstName;
private String lastName;
private String title;
private String companyID;
}
前端响应
前端基于Ajax的请求结果做不同的展现:
- 超时,Connection timeout. Please try again
- 200,保存后端返回的信息,如token,用户信息等
- 400,Invalid username or password. Please try again or use Forget Password link below.
- 401,Invalid username or password. Please try again or use Forget Password link below.
- 423,Your account is locked due to too many times failure.
- 500,Server error. Please try again.
注:登录认证过程永远不要告诉用户到底是用户名错误还是密码错误。只需要给出大概的提示:Invalid username or password
。这可以防止攻击者在不知道密码的情况下,遍历出有效的用户名。
登出认证
登出时前端发送一个/logout
GET请求,只需要在http请求头的x-access-token
中添加token,不需要额外添加用户信息,因为在token的payload中已经携带了accoutID
信息。
headers: {
'x-access-token': sessionStorage.token,
}
后端首先要解析token,确保token未篡改, 并在有效期间内
从token的payload中获取accoutID
,并在account_access_info表中查找,如果找不到用户,则返回401User attempts to logout an account with no access information in the database
判断是移动端还是web端发送的请求,然后将相应的token删除,并返回200
忘记密码认证
在前端的登录页面有Forget password
的按钮,当用户忘记密码,通过POST /passport/forgotpassword
请求并携带用户名
后端首先验证用户名Validators.validateEmailAddress
(要永远对前端的请求保持怀疑态度)
从account表查找用户名,如果不存在,返回400 Invalid username
生成resetpasswordtoken
和reset_password_token_expire
,过期时间可以设定为2小时内有效,更新数据库
生成邮件,邮件内有url,用户名字等信息
返回200
用户的邮箱会收到一封邮件,提示重置密码:
You recently requested a password reset for your ECS Monitoring System account. Please click on the below link to continue resetting your password.
接着会执行重置密码的认证
重置密码认证
有两种情况会执行重置密码的认证:一是用户忘记密码, 执行忘记密码的认证之后,用户会收到重置密码的邮件;二是管理员在后台管理界面新增加一个用户,该用户会收到一封创建帐号的邮件。不论是上述哪种情况,在邮件中会有一个/resetpassword/{token}
的URL,用于跳转到重置密码的界面。
后端收到/resetpassword/{token}
的GET请求后,验证token是否为空,否则返回400 Missing token
在account
表中查找resetpasswordtoken
,如果没有,返回400,Invalid token。如果有,返回200
UI服务器接收到Auth服务器的200响应后,将重置密码页面展示给用户:
router.get('/resetpassword/:token', (req, res) => {
authenticationMiddleware.validateResetPageToken(req, res, (reqNext, resNext) => {
resNext.status(200).set('Content-Type', 'text/html')
.sendFile(path.join(__dirname, '../public/resetpassword.html'));
});
});
在重置密码页面,用户POST/resetpassword/{token}
请求,请求体中携带password
, confirmPassword
后端验证token
, password
, confirmPassword
是否为空,否则返回400 Missing required filed
验证password
, confirmPassword
是否相等,否则返回400 Password doesn't match with confirm password
验证密码是否有效Validators.validatePassword
,否则返回400 Password must be 8-16 characters with at least one uppercase character, one lowercase character, one number and one special character
通过resetpasswordtoken
查account表,验证是否有有效用户,否则返回400
通过account表的resetPasswordTokenExpire
字段验证当前token是否过期(重置密码2个小时之内有效)
通过PasswordUtil.encryptPassword
通过生成salt
和hashpassword
修改account表并保存,注:应该删除resetPasswordToken
, resetPasswordTokenExpire
字段
accountEntity.setSalt(passwordEncryptionResponseModel.getSalt());
accountEntity.setPassword(passwordEncryptionResponseModel.getHashPassword());
// resetPasswordToken and resetPasswordTokenExpire will be useless. So remove them
accountEntity.setResetPasswordToken(null);
accountEntity.setResetPasswordTokenExpire(null);
accountEntity.setUpdateCount(accountEntity.getUpdateCount() + 1);
accountEntity.setLastUpdateDateTime(now);
accountRepository.save(accountEntity);
返回200,并给用户发送一封邮件通知密码已经重置成功
Dear ${firstname} ${lastname}
You recently changed your password for xxx System.
If you didn't make this password change or if you believe unauthorized person has accessed your account, please go to Forgot Password to reset your password immediately.
更改密码认证
- 验证password, confirmPassword是否相同
- 验证currentPassword, password是否相同
-
Validators.validatePassword
验证currentPassword, password, confirmPassword是否是有效的密码格式 - 通过
JwtUtil.parseToken(token)
验证token,并解析出accountID - 使用accountID从account表中获取用户信息
-
PasswordUtil.checkPassword(currentPassword, accountEntity.getSalt(), accountEntity.getPassword()))
,验证currentPassword是否正确 - 确认当前密码输入正确后,使用
PasswordUtil.encryptPassword(password)
新密码生成salt
和hashPassword
- 在account表中更新当前用户的信息:
accountEntity.setSalt(passwordEncryptionResponseModel.getSalt());
accountEntity.setPassword(passwordEncryptionResponseModel.getHashPassword());
accountEntity.setUpdateCount(accountEntity.getUpdateCount() + 1);
accountEntity.setLastUpdateDateTime(now);
accountRepository.save(accountEntity);
- 请求成功,返回200
Token认证
使用RESTful API向服务端发送请求的时候,需要对请求资源的用户进行身份认证。具体实现为:
用户发起的请求的请求先经过基于Node.js的ui-service,然后先路由到/passport/verifymobiletoken
接口进行token验证
router.get('/reports', (req, res) => {
authenticationMiddleware.validateCommonAccessToken(req, res, (reqNext, resNext, body) => {
forwardMiddleware.forwardQueryRequest(config.server.query, reqNext, resNext, body);
});
});
认证服务器首先对token进行验证和解析,获取payload中的accountID
在account表中查找是否用户存在
在其他表中查找跟用户有关的信息(该信息是RESTful请求需要的信息)
返回200
ui-service接受到auth服务器的200响应后,将请求转发到query服务器。否则认证失败,将页面定向到登录页面
if (token) {
const postOpts = {
url: isMobile ? config.servicePath.verifymobiletoken : config.servicePath.verifywebtoken,
method: 'POST',
rejectUnauthorized: false,
headers: { 'Content-Type': 'application/json' },
json: { token },
};
request.post(postOpts, (err, httpResponse, body) => {
...
return next(req, res, body);
});
} else {
return res.redirect('/passport/login');
}
Web端和IOS端的认证
由于IOS和Web分别使用各自的token进行认证, 而两者在认证的业务逻辑上没有任何区别,因此可以在公用一套代码逻辑,以Token认证过程为例:
@PostMapping(value = {"/verifywebtoken","/verifymobiletoken"})
public ResponseEntity