美文网首页我爱编程
Web认证相关总结

Web认证相关总结

作者: 智明书 | 来源:发表于2018-03-28 18:52 被阅读86次

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


    登出认证

    登出时前端发送一个/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


    用户的邮箱会收到一封邮件,提示重置密码:

    <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 &gt;</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通过生成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,并给用户发送一封邮件通知密码已经重置成功

    <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)新密码生成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<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); 
    }
    

    相关文章

      网友评论

        本文标题:Web认证相关总结

        本文链接:https://www.haomeiwen.com/subject/fcpucftx.html