Web认证相关总结

​ 总结在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, 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。这可以防止攻击者在不知道密码的情况下,遍历出有效的用户名。


登出认证

登出时前端发送一个/logoutGET请求,只需要在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

生成resetpasswordtokenreset_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.

Reset 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通过生成salthashpassword

修改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)新密码生成salthashPassword
  • 在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> 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); 
}

你可能感兴趣的:(Web认证相关总结)