总结在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<Map<String, Object>>(map, HttpStatus.BAD_REQUEST);
}
数据库验证
- 验证用户输入的
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
用户的邮箱会收到一封邮件,提示重置密码:
<p>
You recently requested a password reset for your ECS Monitoring System account. Please click on the below link to continue resetting your password.
</p>
<p>
<a href="${resetpassword}">Reset Your Password ></a>
</p>
接着会执行重置密码的认证
重置密码认证
有两种情况会执行重置密码的认证:一是用户忘记密码, 执行忘记密码的认证之后,用户会收到重置密码的邮件;二是管理员在后台管理界面新增加一个用户,该用户会收到一封创建帐号的邮件。不论是上述哪种情况,在邮件中会有一个/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,并给用户发送一封邮件通知密码已经重置成功
<p>Dear ${firstname} ${lastname}</p>
<p>You recently changed your password for xxx System.</p>
<p>If you didn't make this password change or if you believe unauthorized person has accessed your account, please go to <a href="xxx.xxx.com">Forgot Password</a> to reset your password immediately.</p>
更改密码认证
- 验证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<Map<String,Object>> handleVerifyToken(
HttpServletRequest httpRequest,
@RequestBody TokenRequestModel tokenRequestModel) throws FileNotFoundException{
// distinguish request from web portal or mobile app
final String requestMapping = (String) httpRequest.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
final boolean isMobile = requestMapping.contains("/verifymobiletoken");
...
return authenticationService.handleVerifyToken(token,isMobile);
}