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<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); 
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,744评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,505评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,105评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,242评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,269评论 6 389
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,215评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,096评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,939评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,354评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,573评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,745评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,448评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,048评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,683评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,838评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,776评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,652评论 2 354

推荐阅读更多精彩内容