😎知识点概览

为了方便后续回顾该项目时能够清晰的知道本章节讲了哪些内容,并且能够从该章节的笔记中得到一些帮助,所以在完成本章节的学习后在此对本章节所涉及到的知识点进行总结概述。

本章节为【学成在线】项目的 day17 的内容

  • 构建用户中心服务,并基于 Spring Security Oauth2 以及 jwt 令牌实现用户认证的完整流程。
  • 完成门户网站的用户登入、登出接口、前端页面的开发以及调试。
  • 基于 Zuul 构建网关服务,以及使用 Zuul 网关实现基本的路由转发、过滤器、身份校验等功能

一、用户认证

1. 用户认证流程分析

用户认证流程如下:

image-20200527083511311

业务流程说明如下:

1、客户端请求认证服务进行认证。

2、认证服务认证通过向浏览器 cookie 写入 token (身份令牌)

认证服务请求用户中心查询用户信息。

认证服务请求 Spring Security 申请令牌。

认证服务将 token (身份令牌)和 jwt 令牌存储至 redis 中。

认证服务向cookie写入 token (身份令牌)。

3、前端携带token请求认证服务获取jwt令牌

前端获取到 jwt 令牌并存储在 sessionStorage

前端从jwt令牌中解析中用户信息并显示在页面。

前端如何解析?还是认证服务返回明文数据

4、前端携带cookie中的token身份令牌及jwt令牌访问资源服务

前端请求资源服务需要携带两个token,一个是cookie中的身份令牌,一个是http header中的jwt令牌

前端请求资源服务前在http header上添加jwt请求资源

5、网关校验 token的合法性

用户请求必须携带 token 身份令牌和jwt令牌

网关校验redis中 token 是否合法,已过期则要求用户重新登录

6、资源服务校验jwt的合法性并完成授权

资源服务校验jwt令牌,完成授权,拥有权限的方法正常执行,没有权限的方法将拒绝访问。

2. 认证服务查询数据库

需求分析

  • 认证服务根据数据库中的用户信息去校验用户的身份,即校验账号和密码是否匹配。
  • 认证服务不直接连接数据库,而是通过用户中心服务去查询用户中心数据库。

image-20200527083511311

完整的流程图如下:

image-20200527084051627

搭建环境

1、创建用户中心数据库

用户中心负责用户管理,包括:用户信息管理、角色管理、权限管理等。

创建 xc_user 数据库(MySQL)

导入 xc_user.sql (已导入不用重复导入)

image-20200527084200019

2、创建用户中心工程

导入“资料”-》xc-service-ucenter.zip

image-20200527084302385

完成用户中心根据账号查询用户信息接口功能。

查询用户接口开发

1、Api接口

用户中心对外提供如下接口

1)响应数据类型

此接口将来被用来查询用户信息及用户权限信息,所以这里定义扩展类型

package com.xuecheng.framework.domain.ucenter.response.ext;
import com.xuecheng.framework.domain.ucenter.XcMenu;
import com.xuecheng.framework.domain.ucenter.XcUser;
import lombok.Data;
import lombok.ToString;
 
import java.util.List;
 
@Data
@ToString
public class XcUserExt extends XcUser {
    //权限信息
    private List<XcMenu> permissions;
    //企业信息
    private String companyId;
}

2)根据账号查询用户信息

package com.xuecheng.api.ucenter;
 
import com.xuecheng.framework.domain.ucenter.response.ext.XcUserExt;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
 
@Api(value = "用户中心",description = "用户中心管理")
public interface UcenterControllerApi {
    
    @ApiOperation("获取用户信息")
    public XcUserExt getUserext(String username);
}

2、DAO

添加 XcUserXcCompantUser 两个表的Dao ,对于一些简单的sql操作,我们使用 Spring Data JPA 实现

public interface XcUserRepository extends JpaRepository<XcUser, String> {
    XcUser findXcUserByUsername(String username);
}
public interface XcCompanyUserRepository extends JpaRepository<XcCompanyUser,String> {
    //根据用户id查询所属企业id
    XcCompanyUser findByUserId(String userId);
}

3、Service

@Service
public class UserServiceImpl implements UserService {
 
    //对xc_user表的相关操作
    @Autowired
    XcUserRepository xcUserRepository;
 
    //对xc_company_user表的相关操作
    @Autowired
    XcCompanyUserRepository xcCompanyUserRepository;
 
    /**
     * 根据用户名查询用户信息的实现
     * @param username
     * @return
     */
    @Override
    public XcUser findXcUserByUsername(String username) {
        return xcUserRepository.findXcUserByUsername(username);
    }
 
    /**
     * 根据用户名获取用户权限的实现
     * @param username 用户名
     * @return
     */
    @Override
    public XcUserExt getUserExt(String username) {
        //查询用户信息
        XcUser xcUser = this.findXcUserByUsername(username);
        if(xcUser ==null) return null;
 
        XcUserExt xcUserExt = new XcUserExt();
        BeanUtils.copyProperties(xcUser,xcUserExt);
 
        //根据用户id查询用所属公司
        String xcUserId = xcUser.getId();
        XcCompanyUser xcCompanyUser = xcCompanyUserRepository.findByUserId(xcUserId);
        if(xcCompanyUser!=null){
            String companyId = xcCompanyUser.getCompanyId();
            xcUserExt.setCompanyId(companyId);
        }
 
        //返回XcUserExt对象
        return xcUserExt;
    }
}

4、Controller

@RestController
@RequestMapping("/ucenter")
public class UcenterController implements UcenterControllerApi {
    @Autowired
    UserService userService;
    @Override
    @GetMapping("/getuserext")
    public XcUserExt getUserext(@RequestParam("username") String username) {
        XcUserExt xcUser = userService.getUserExt(username);
        return xcUser;
    }
}

5、可能出现的一些问题

如果 ucenter 服务出现接口需要认证才能访问的情况,考虑可能是继承了 model 工程的 oauth2 依赖导致开启了认证拦截。

解决方案:在 model 工程下的 oauth2 依赖加上 <optional>true</optional> 标签,该标签可以防止本工程下的依赖包传递到其他工程。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    <optional>true</optional>
</dependency>

6、测试

使用 Swagger-uipostman 测试用户信息查询接口

GET http://localhost:40300/ucenter/getuserext

参数为 username

img

7、思考一些问题

在上述测试过程中,通过 GET 请求调用 http://localhost:40300/ucenter/getuserext 接口可以获取到一个用户的详细信息,但是考虑到用户数据的安全问题,这个接口不应该直接暴露给普通的用户,只适合服务间的调用,并需要经过授权的服务才可以调用。

答:后期配置微服务间认证后可以解决上述的问题。

调用查询用户的接口

1、创建 client

认证服务需要远程调用用户中心服务查询用户,在 认证服务 中创建Feign客户端

@FeignClient(value = XcServiceList.XC_SERVICE_UCENTER)
public interface UserClient {
    @GetMapping("/ucenter/getuserext")
    public XcUserExt getUserext(@RequestParam("username") String username)
}

2、UserDetailsService

认证服务调用 spring security 接口申请令牌,spring security 接口会调用 UserDetailsServiceImpl 从数据库查询用户,如果查询不到则返回 NULL,表示不存在;在UserDetailsServiceImpl 中将正确的密码返回, spring security 会自动去比对输入密码的正确性。

修改 UserDetailsServiceImpl 的 loadUserByUsername 方法,调用 Ucenter服务的查询用户接口

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
 
    @Autowired
    ClientDetailsService clientDetailsService;
 
    //用户中心服务客户端
    @Autowired
    UserClient userClient;
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //取出身份,如果身份为空说明没有认证
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //没有认证统一采用httpbasic认证,httpbasic中存储了client_id和client_secret
        //开始认证client_id和client_secret
        if(authentication==null){
            ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
            if(clientDetails!=null){
                //密码
                String clientSecret = clientDetails.getClientSecret();
                return new User(username,clientSecret,AuthorityUtils.commaSeparatedStringToAuthorityList(""));
            }
        }
        if (StringUtils.isEmpty(username)) {
            return null;
        }
 
        //请求ucenter查询用户
        XcUserExt userext = userClient.getUserext(username);
        if(userext == null) return null; //如果获取到的用信息为空,则返回null,spring security则会抛出异常
 
        //设置用户的认证和权限信息
        userext.setUsername("itcast");
        userext.setPassword(new BCryptPasswordEncoder().encode("123"));
        userext.setPermissions(new ArrayList<XcMenu>());  //这里授权部分还没完成,所以先填写静态的
        if(userext == null){
            return null;
        }
 
        //从数据库查询用户正确的密码,Spring Security会去比对输入密码的正确性
        String password = userext.getPassword();
        String user_permission_string = "";
 
        //设置用户信息到userDetails对象
        UserJwt userDetails = new UserJwt(
                username,
                password,
                AuthorityUtils.commaSeparatedStringToAuthorityList(user_permission_string));
        //用户id
        userDetails.setId(userext.getId());
        //用户名称
        userDetails.setName(userext.getName());
        //用户头像
        userDetails.setUserpic(userext.getUserpic());
        //用户所属企业id
        userDetails.setCompanyId(userext.getCompanyId());
 
        //返回用信息给到Spring Security进行处理
        return userDetails;
    }
}

3、BCryptPaswordEncoder

早期使用md5对密码进行编码,每次算出的md5值都一样,这样非常不安全,Spring Security推荐使用
BCryptPasswordEncoder对密码加随机盐,每次的Hash值都不一样,安全性高 。

1)BCryptPasswordEncoder测试程序如下

@Test
public void testPasswrodEncoder(){
    String password = "111111";
    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    for(int i=0;i<10;i++) {
        //每个计算出的Hash值都不一样
        String hashPass = passwordEncoder.encode(password);
        System.out.println(hashPass);
        //虽然每次计算的密码Hash值不一样但是校验是通过的
        boolean f = passwordEncoder.matches(password, hashPass);
        System.out.println(f);
    }
}

2)在 AuthorizationServerConfig 配置类中配置 BCryptPasswordEncoder

原教程中已经在 WebSecurityConfig 中进行了配置,这个在哪里配置都无所谓,本质上都是向spring注入一个bean

//采用bcrypt对密码进行Hash
@Bean
public PasswordEncoder passwordEncoder() {
	return new BCryptPasswordEncoder();
}

3)测试

请求 http://localhost:40400/auth/userlogin,输入正常的账号和密码进行测试

image-20200529102941282

4、解析申请令牌错误信息

当账号输入错误应该返回用户不存在的信息,当密码错误要返回用户名或密码错误信息,业务流程图如下:

image-20200527103015749

修改申请令牌的程序解析返回的错误:

由于 restTemplate 收到400或401的错误会抛出异常,而 spring security 针对账号不存在及密码错误会返回 400401,所以在代码中控制针对 400401 的响应不要抛出异常。

修改 AuthServiceImpl 的 appleToken 方法

//向Oauth2服务申请令牌
private AuthToken appleToken(String username, String password, String clientId, String clientSecret){
    //采用客户端负载均衡的方式从eureka获取认证服务的ip和端口
    ServiceInstance serviceInstance = loadBalancerClient.choose("XC-SERVICE-UCENTER-AUTH");
    URI uri = serviceInstance.getUri();
    String authUrl = uri + "/auth/oauth/token";
 
    //使用LinkedMultiValueMap储存多个header信息
    LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    //设置basic认证信息
    String basicAuth = this.getHttpBasic(clientId, clientSecret);
    headers.add("Authorization",basicAuth);
 
    //设置请求中的body信息
    LinkedMultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type","password");
    body.add("username",username);
    body.add("password",password);
    HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(body, headers);
 
    //凭证信息错误时候, 指定restTemplate当遇到400或401响应时候也不要抛出异常,也要正常返回值
    restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
        @Override
        public void handleError(ClientHttpResponse response) throws IOException {
            //当响应的值为400或者401时也要正常响应,不要抛出异常
            if(response.getRawStatusCode()!=400 && response.getRawStatusCode()!=401){
                super.handleError(response);
            }
        }
    });
 
    Map map = null;
    try {
        ((RestTemplate) restTemplate).setErrorHandler(new DefaultResponseErrorHandler() {
            @Override
            public void handleError(ClientHttpResponse response) throws IOException {
                // 设置 当响应400和401时照常响应数据,不要报错
                if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401 ) {
                    super.handleError(response);
                }
            }
        });
 
        //http请求spring security的申请令牌接口
        ResponseEntity<Map> mapResponseEntity = restTemplate.exchange(authUrl, HttpMethod.POST, new
                                                                      HttpEntity<MultiValueMap<String, String>>(body, headers), Map.class);
        map = mapResponseEntity.getBody();
    } catch (Exception e) {
        e.printStackTrace();
        LOGGER.error("request oauth_token_password error: {}",e.getMessage());
        e.printStackTrace();
        ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
    }
    if(map == null ||map.get("access_token") == null ||
       map.get("refresh_token") == null ||
       map.get("jti") == null){//jti是jwt令牌的唯一标识作为用户身份令牌
        //获取spring security返回的错误信息
        String error_description = (String) map.get("error_description");
        if(StringUtils.isNotEmpty(error_description)){
            if(error_description.equals("坏的凭证")){
                ExceptionCast.cast(AuthCode.AUTH_CREDENTIAL_ERROR);
            }else if(error_description.indexOf("UserDetailsService returned null")>=0){
                ExceptionCast.cast(AuthCode.AUTH_ACCOUNT_NOTEXISTS);
            }
        } ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
    }
 
    //拼装authToken并返回
    AuthToken authToken = new AuthToken();
    //访问令牌(jwt)
    String access_token = (String) map.get("access_token");
    //刷新令牌(jwt)
    String refresh_token = (String) map.get("refresh_token");
    //jti,作为用户的身份标识,也就是后面我们用于返回给到用户前端的凭证
    String jwt_token = (String) map.get("jti");
 
    authToken.setAccess_token(access_token);
    authToken.setRefresh_token(refresh_token);
    authToken.setJwt_token(jwt_token);
    return authToken;
}

用户不存在:

image-20200527103916066

密码错误:

image-20200527103926094

5、测试

使用postman请求http://localhost:40400/auth/userlogin

1、输入正确的账号和密码进行测试

从数据库找到测试账号,本课程所提供的用户信息初始密码统一为123

image-20200529104816836

2、输入错误的账号和密码进行测试

image-20200529104802458

3. 用户登录前端

需求分析

点击用户登录固定跳转到用户中心前端的登录页面,如下:

image-20200527085248145

输入账号和密码,登录成功,跳转到首页。

用户中心前端(xc-ui-pc-learning工程)提供登录页面,所有子系统连接到此页面。

说明:

  • 页面有 “登录|注册” 链接的前端系统有:门户系统、搜索系统、用户中心。
  • 本小节修改门户系统的页头,其它三处可参考门户修改。

Api方法

xc-ui-pc-leanring/src/base/api/login.js 下配置该api方法,用于请求后端登录接口

/*登陆*/
export const login = params => {
    //let loginRequest = querystring.stringify(params)
    let loginRequest = qs.stringify(params);
    return http.requestPostForm('/openapi/auth/userlogin',loginRequest);
}

页面

1、登录页面

进入用户中心前端 xc-ui-pc-leanring/src/module/home/page/,找到登录页面 loginpage.vue

image-20200527085434263

loginpage.vue 导入了 loginForm.vue 组件,loginForm.vue 页面包括了登录表单

<template>
  <div>
    <p-head></p-head>
 
    <login-form></login-form>
 
    <p-foot></p-foot>
  </div>
</template>
<script>
import PHead from '@/base/components/head.vue';
import PFoot from '@/base/components/foot.vue';
import loginForm from '@/base/components/loginForm.vue';
...

xc-ui-pc-leanring/src/base/components 下我们可以看到一个 loginForm.vue 的页面文件,主要为登录表单的页面实现,部分页面代码如下

<template>
  <div>
        <el-row class="container" style="width: 470px">
          <div id="body">
            <div class="g-center login-page" @keyup.enter="login">
              <el-tabs v-model="activeName" >
                <el-tab-pane label="用户登陆" name="login">
              <el-form :model="loginForm" label-width="80px" :rules="loginRules" ref="loginForm" class="login-form">
                <el-form-item label="账号" prop="username">
                  <el-input v-model="loginForm.username" auto-complete="off" ></el-input>
                </el-form-item>
                <el-form-item label="密码" prop="password">
                  <el-input v-model="loginForm.password" auto-complete="off" ></el-input>
                </el-form-item>
                <el-form-item >
                  <el-button type="primary"  @click.native="login" :loading="editLoading">登陆</el-button>
                  <el-button type="primary"  @click="resetForm('loginForm')">重置</el-button>
                </el-form-item>
              </el-form>
                </el-tab-pane>
                <el-tab-pane label="用户注册" name="register">
                  建设中..
                </el-tab-pane>
              </el-tabs>
            </div>

        </div>
        </el-row>
  </div>
</template>

2、路由配置

来到 xc-ui-pc-leanring/src/module/home/router 下配置home模块的路由:

import Home from '@/module/home/page/home.vue';
import Login from '@/module/home/page/loginpage.vue';
import Denied from '@/module/home/page/denied.vue';
import Logout from '@/module/home/page/logout.vue';
import order_pay from '@/module/order/page/order_pay.vue';
export default [{
    path: '/',
    component: Home,
    name: '个人中心',
    hidden: true
},
{
	path: '/login',
	component: Login,
	name: 'Login',
 	hidden: true
},
   .....

3、登录后跳转

请求登录页面需携带 returnUrl 参数,要求此参数使用 Base64 编码。

登录成功后将跳转到 returnUrlloginForm.vue 组件的登录方法如下:

login: function () {
    this.$refs.loginForm.validate((valid) => {
        if (valid) {
            this.editLoading = true;
            let para = Object.assign({}, this.loginForm);
            loginApi.login(para).then((res) => {
                this.editLoading = false;
                if(res.success){
                    this.$message('登陆成功');
                    //刷新 当前页面
                    // alert(this.returnUrl)
                    console.log(this.returnUrl)
                    if(this.returnUrl!='undefined' && this.returnUrl!=''
                       && !this.returnUrl.includes("/userlogout")
                       && !this.returnUrl.includes("/userlogin")){
                        window.location.href = this.returnUrl;
                    }else{
                        //跳转到首页
                        window.location.href = 'http://www.xuecheng.com/'
                    }
                }else{
                    if(res.message){
                        this.$message.error(res.message);
                    }else{
                        this.$message.error('登陆失败');
                    }
                }
            },
                                      (res) => {
                this.editLoading = false;
            });
        }
    });
},

点击登录页面

在门户的页头点击“登录|注册”连接到用户中心的登录页面,并且携带 returnUrl
修改门户的 header.html,代码如下:

<a href="javascript:;" @click="showlogin" v-if="logined == false">登陆&nbsp;|&nbsp;注册</a>

配置 showlogin 方法

showlogin: function(){
    //this.loginFormVisible = true;
    window.location = "http://ucenter.xuecheng.com/#/login?returnUrl="+
        Base64.encode(window.location)
}

测试

测试之前修改认证服务的配置:

修改 application.yml 中 cookie 域名

cookieDomain: xuecheng.com

测试流程如下:

1、输入www.xuecheng.com进入系统(需要在hosts文件配置)

2、输入正确的账号和密码,提交

image-20200529111655698

3、输入错误的账号和密码,提交

image-20200529111704463

登录成功,观察 cookie 是否存储成功:

image-20200527085814933

二、前端显示当前用户

1. 需求分析

用户登录成功在页头显示当前登录的用户名称。

数据流程如下图:

image-20200529113051020

1、用户请求认证服务,登录成功。

2、用户登录成功,认证服务向 cookie 写入身份令牌,向 redis 写入 user_token(身份令牌及授权jwt授权令牌)

3、客户端携带 cookie 中的身份令牌请求认证服务获取 jwt 令牌。

4、客户端解析 jwt 令牌,并将解析的用户信息存储到 sessionStorage 中。jwt令牌中包括了用户的基本信息,客户端解析jwt令牌即可获取用户信息。

5、客户端从sessionStorage中读取用户信息,并在页头显示。

sessionStorage 是H5的一个会话存储对象,在 SessionStorage中保存的数据只在同一窗口或同一标签页中有效,在关闭窗口之后将会删除SessionStorage中的数据。seesionStorage 的存储方式采用key/value的方式,可保存5M左右的数据(不同的浏览器会有区别)

sessionStorage 是H5的一个会话存储对象,在 SessionStorage中保存的数据只在同一窗口或同一标签页中有效,
在关闭窗口之后将会删除SessionStorage中的数据。

seesionStorage的存储方式采用key/value的方式,可保存5M左右的数据(不同的浏览器会有区别)

2. jwt查询接口

该接口我们在 ucenter-auth 服务下进行开发

需求分析

认证服务对外提供jwt查询接口,流程如下:

1、客户端携带 cookie 中的身份令牌请求认证服务获取 jwt

2、认证服务根据身份令牌从 redis 中查询 jwt 令牌并返回给客户端。

API

在认证模块定义 jwt 查询接口:

@Api(value = "jwt查询接口",description = "客户端查询jwt令牌内容")
public interface AuthControllerApi {
    @ApiOperation("查询userjwt令牌")
    public JwtResult userjwt();
    ....

Dao

Service

在AuthService中定义方法如下:

//从redis查询令牌
public AuthToken getUserToken(String token){
    String userToken = "user_token:"+token;
    String userTokenString = stringRedisTemplate.opsForValue().get(userToken);
    if(userToken!=null){
        AuthToken authToken = null;
        try {
            authToken = JSON.parseObject(userTokenString, AuthToken.class);
        } catch (Exception e) {
            LOGGER.error("getUserToken from redis and execute JSON.parseObject error{}",e.getMessage());
            e.printStackTrace();
        } 
        return authToken;
    } 
    return null;
}

Controller

@Override
@GetMapping("/userjwt")
public JwtResult userjwt() {
    //获取cookie中的令牌
    String access_token = getTokenFormCookie();
    //根据令牌从redis查询jwt
    AuthToken authToken = authService.getUserToken(access_token);
    if(authToken == null){
        return new JwtResult(CommonCode.FAIL,null);
    } 
    return new JwtResult(CommonCode.SUCCESS,authToken.getJwt_token());
} 
//从cookie中读取访问令牌
private String getTokenFormCookie(){
    Map<String, String> cookieMap = CookieUtil.readCookie(request, "uid");
    String access_token = cookieMap.get("uid");
    return access_token;
}

WebSecurityConfig 配置放行 userjwt

class WebSecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/userlogin","/userlogout","/getjwt","/swagger-ui.html");
    }

测试

使用 postman 测试

1、请求 /auth/userlogin

image-20200530091705402

观察 cookie 是否已存入用户身份令牌

2、get请求jwt

image-20200529114126146

3. 前端请求jwt

需求分析

前端需求如下:

用户登录成功,前端请求认证服务获取jwt令牌。

前端解析jwt令牌的内容,得到用户信息,并将用户信息存储到 sessionStorage。

从 sessionStorage 取出用户信息在页头显示用户名称。

以下操作我们在门户工程进行

API方法

在login.js中定义getjwt方法:

/*获取jwt令牌*/
const getjwt = () => {
	return requestGet('/openapi/auth/userjwt');
}

页面

修改 include/header.html

1、页面视图

<span v-if="logined == true">欢迎{{this.user.username}}</span>
<a href="javascript:;" @click="logout" v-if="logined == true">退出</a>
<a href="http://ucenter.xuecheng.com/" class="personal" target="_blank">我的学习</a>
<a href="javascript:;" @click="showlogin" v-if="logined == false">登陆&nbsp;|&nbsp;注册</a>
<a href="http://teacher.xuecheng.com/" class="personal" target="_blank">教学提供方</a>
<a href="http://system.xuecheng.com/" class="personal" target="_blank">系统后台</a>

用户登录成功设置数据对象 loginedtrue,设置数据对象 user 为当前用户信息。

数据对象定义如下

user:{
    userid:'',
    username: '',
    userpic: ''
},
logined:false

2、解析jwt令牌

util.js 中定义解析jwt令牌方法:

//解析jwt令牌,获取用户信息
var getUserInfoFromJwt = function (jwt) {
    if(!jwt){
        return ;
    } 
    var jwtDecodeVal = jwt_decode(jwt);
    if (!jwtDecodeVal) {
        return ;
    } 
    let activeUser={}
    //console.log(jwtDecodeVal)
    activeUser.utype = jwtDecodeVal.utype || '';
    activeUser.username = jwtDecodeVal.name || '';
    activeUser.userpic = jwtDecodeVal.userpic || '';
    activeUser.userid = jwtDecodeVal.userid || '';
    activeUser.authorities = jwtDecodeVal.authorities || '';
    activeUser.uid = jwtDecodeVal.jti || '';
    activeUser.jwt = jwt;
    return activeUser;
}

3、refresh_user()

mounted 钩子方法中调用 refresh_user 获取当前用户信息,并将用户信息存储到 sessionStorage

mounted(){
//刷新当前用户
this.refresh_user()
}

refresh_user() 方法如下:

refresh_user:function(){
    //从sessionStorage中取出当前用户
    let activeUser= getActiveUser();
    //取出cookie中的令牌
    let uid = getCookie("uid")
        //console.log(activeUser)
        if(activeUser && uid && uid == activeUser.uid){
            this.logined = true
                this.user = activeUser;
        }else{
            if(!uid){
                return ;
            } 
            //请求查询jwt
            getjwt().then((res) => {
                if(res.success){
                    let jwt = res.jwt;
                    let activeUser = getUserInfoFromJwt(jwt)
                        if(activeUser){
                            this.logined = true
                                this.user = activeUser;
                            setUserSession("activeUser",JSON.stringify(activeUser))
                        }
                }
            })
        }
}

配置代理转发

上边实现在首页显示当前用户信息,首页需要通过 Nginx 代理请求认证服务,所以需要在 www 域下的虚拟主机上配置代理路径:

#认证
location ^~ /openapi/auth/ {
	proxy_pass http://auth_server_pool/auth/;
}

注意:其它前端系统要接入认证要请求认证服务也需要配置上边的代理路径。

测试

登录成功后自动跳转回到门户主站,并显示用户的信息

img

三、用户退出

1. 需求分析

操作流程如下:

1、用户点击退出,弹出退出确认窗口,点击确定

image-20200530095137916

2、退出成功

image-20200530095144891

用户退出要完成以下动作:

1、删除 redis 中的 token

2、删除 cookie 中的 token

2. API

认证服务对外提供退出接口。

@ApiOperation("退出")
public ResponseResult logout();

3. 服务端

认证服务提供退出接口。

DAO

Service

/**
     * 删除指定usertoken在redis中的jwt信息
     * @param uid usertoken
     * @return
     */
@Override
public Boolean delToken(String uid) {
    String key = "user_token:" + uid;
    Boolean delete = stringRedisTemplate.delete(key);
    return delete;
}

Controller

@PostMapping("/userlogout")
@Override
public ResponseResult logout() {
    // 取出用户身份令牌
    String uid = getTokenFormCookie();
    //删除用户在redis中的身份信息
    Boolean delToken = authService.delToken(uid);
    //通过修改返回的response来实现用户前端收到响应后删除浏览器的cookie信息
    clearCookie(uid);
    if(delToken){
        return new ResponseResult(CommonCode.SUCCESS);
    }
    return new ResponseResult(CommonCode.FAIL);
}
 
//清除cookie
private void clearCookie(String token){
    HttpServletResponse response = ((ServletRequestAttributes)
                                    RequestContextHolder.getRequestAttributes()).getResponse();
    // 设置maxAge为实现删除cookie
    CookieUtil.addCookie(response, cookieDomain, "/", "uid", token, 0, false);
}

退出URL放行

认证服务默认都要校验用户的身份信息,这里需要将退出url放行。

WebSecurityConfig 类中重写 configure (WebSecurity web)方法,如下:

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/userlogin","/userlogout","/userjwt","/swagger-ui.html");
}

4. 前端

需求分析

在用户中心前端工程(xc-ui-pc-learning)开发退出页面。

Api方法定义

在用户中心工程增加退出的 api 方法

base 模块的 login.js 增加方法如下:

/*退出*/
export const logout = params => {
    return http.requestPost('/openapi/auth/userlogout');
}

退出页面

1、在用户中心工程创建退出页面

参考: xc-ui-pc-leanring/src/module/home/page/logout.vue

image-20200530095510125

2、路由配置

import Logout from '@/module/home/page/logout.vue';
import order_pay from '@/module/order/page/order_pay.vue';
// import LoginMini from '@/module/home/page/login_mini.vue';
export default [{
    path: '/',
    component: Home,
    name: '个人中心',
    hidden: true
},
{
    path: '/login',
    component: Login,
    name: 'Login',
    hidden: true
},
{
    path: '/logout',
    component: Logout,
    name: 'Logout',
    hidden: true
},
....

3、退出方法

退出成功清除页面的 sessionStorage

参考 logout.vue

created 钩子方法请求退出方法

created(){
    loginApi.logout({}).then((res) => {
        if(res.success){
            sessionStorage.removeItem('activeUser');
            this.$message('退出成功');
            this.logoutsuccess = true
        }else if(res.code == 11111){
            // 用户的登录信息已在redis过期
            this.logoutsuccess = false
            this.$message('用户凭证过期, 在这之前已经退出登陆');
        } else{
            this.logoutsuccess = false
        }
        //清空用户的缓存信息
        sessionStorage.clear()
    },
                             (res) => {
        this.logoutsuccess = false
    });
},

链接到退出页面

修改门户主站 xc-ui-pc-static-portal 的 include/header.html

<a href="javascript:;" @click="logout" v-if="logined == true">退出</a>

include/header.html 中添加 element-ui 库,将此js加到 head 的最下边

否则可能会出现无法加载element-ui组件的问题

<script src="/css/el/index.js"></script>

logout 方法如下:

logout: function () {
    this.$confirm('确认退出吗?', '提示', {
    }).then(() => {
        //跳转到用户中心的登出页面
        window.location = "http://ucenter.xuecheng.com/#/logout"
    }).catch(() => {
        
    });
},

测试效果

image-20200530111647702

一些问题

下述的一些问题在我上面的代码中其实已经修复,但部分读者可能跳过了上述的步骤,仍然使用的是原教程中所给到的代码案例,所以这里的一些问题我单独列出来。

1、用户的登录信息已在redis过期,返回操作的状态码,前端没有识别为已登出的状态

增加对 11111 状态码的判断

created(){
    loginApi.logout({}).then((res) => {
        if(res.success){
            sessionStorage.removeItem('activeUser');
            this.$message('退出成功');
            this.logoutsuccess = true
        }else if(res.code == 11111){
            // 用户的登录信息已在redis过期
            this.logoutsuccess = false
            this.$message('用户凭证过期, 在这之前已经退出登陆');
        } else{
            this.logoutsuccess = false
        }
    },
                             (res) => {
        this.logoutsuccess = false
    });
},

2、登出成功后,返回主页后仍然显示用户的信息

在发送登出请求后,使用 sessionStorage.clear() 清空用户的缓存信息

created(){
    loginApi.logout({}).then((res) => {
        if(res.success){
            sessionStorage.removeItem('activeUser');
            this.$message('退出成功');
            this.logoutsuccess = true
        }else if(res.code == 11111){
            // 用户的登录信息已在redis过期
            this.logoutsuccess = false
            this.$message('用户凭证过期, 在这之前已经退出登陆');
        } else{
            this.logoutsuccess = false
        }
        //清空用户的缓存信息
        sessionStorage.clear()
    },
                             (res) => {
        this.logoutsuccess = false
    });
},

四、Zuul 网关

1. 需求分析

网关的作用相当于一个过虑器、拦截器,它可以拦截多个系统的请求。

本章节要使用网关校验用户的身份是否合法。

2. Zuul 介绍

什么是Zuul?

Spring Cloud Zuul 是整合 Netflix 公司的 Zuul 开源项目实现的微服务网关,它实现了 请求路由负载均衡校验过虑 等功能。

官方:https://github.com/Netflix/zuul

什么是网关?

服务网关是在微服务前边设置一道屏障,请求先到服务网关,网关会对请求进行 过虑校验路由 等处理。有了服务网关可以提高微服务的安全性,网关校验请求的合法性,请求不合法将被拦截,拒绝访问。

Zuul 与 Nginx 怎么配合使用?

Zuul与 Nginx 在实际项目中需要配合使用,如下图,Nginx 的作用是反向代理、负载均衡,Zuul 的作用是保障微服务的安全访问,拦截微服务请求,校验合法性及负载均衡。

image-20200531170255074

3.搭建网关工程

创建网关工程(xc-govern-gateway):

1、创建 xc-govern-gateway 工程

导入 资料/xc-govern-gateway.zip

2、@EnableZulProxy

注意在启动类上使用 @EnableZuulProxy 注解标识此工程为 Zuul 网关,启动类代码如下:

@SpringBootApplication
@EnableZuulProxy
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

4. 路由配置

需求分析

Zuul 网关具有代理的功能,根据请求的url转发到微服务,如下图:

image-20200531170704355

  • 客户端请求网关 /api/learning,通过路由转发到 /learning
  • 客户端请求网关 /api/course,通过路由转发到 /course

路由配置

appcation.yml 中配置:

zuul:
  routes:
	manage-course: #路由名称,名称任意,保持所有路由名称唯一
      path: /course/**
      serviceId: xc-service-manage-course #指定服务id,从Eureka中找到服务的ip和端口
      #url: http://localhost:31200 #也可指定url
      strip-prefix: false #true:代理转发时去掉前缀,false:代理转发时不去掉前缀
      sensitiveHeaders: #默认zuul会屏蔽cookie,cookie不会传到下游服务,这里设置为空则取消默认的黑名单,如果设置了具体的头信息则不会传到下游服务
	  # ignoredHeaders: Authorization
  • serviceId:推荐使用 serviceId,zuul会从 Eureka 中找到服务 id 对应的 ip 和端口。

  • strip-prefix: false或者true,设置为 true 时代理转发时去掉前缀,false 则代理转发时不去掉前缀

    例如,设置为 true 请求 /course/coursebase/get/.. ,代理转发到 /coursebase/get/,如果为false则代理直接转发到原来的url

  • sensitiveHeaders:敏感头设置,默认会过虑掉cookie,这里设置为空表示不过虑

  • ignoredHeaders:可以设置过虑的头信息,默认为空表示不过虑任何头

测试

http://localhost:50201/api 是网关地址,通过路由转发到 xc-service-manage-course 服务。

打算使用课程图片信息获取的 API 进行测试我,这里的课程图片信息获取的URL为 /course/coursepic/get ,所以由于课程管理已经添加了授课拦截,这里为了测试网关功能暂时将 url /course/coursepic/get排除认证。在课程管理服务的 ResourceServerConfig 类中添加 "/course/coursepic/get/*" 代码如下:

@Override
public void configure(HttpSecurity http) throws Exception {
    //所有请求必须认证通过
    http.authorizeRequests()
        //下边的路径放行
        .antMatchers("/v2/api-docs", "/swagger-resources/configuration/ui",
                     "/swagger-resources","/swagger-resources/configuration/security",
                     "/swagger-ui.html","/course/coursepic/list/*")
        .permitAll()
        .anyRequest().authenticated();
}

而我之前的课程中将需要排除的 url 改写成了从配置文件中读取,如下代码,如果是按原教程的配置的,可以忽略下述的代码, 直接阅读测试的环节。

@Value("${oauth2.urlMatchers}")
String urlMatchers;
 
//Http安全配置,对每个到达系统的http请求链接进行校验
@Override
public void configure(HttpSecurity http) throws Exception {
    if(urlMatchers.equals("")){
        //如果urlMatchers未指定,则所有url都需要授权后才能被访问
        http.authorizeRequests().anyRequest().authenticated();
    }else{
        //放行 urlMatchers 中指定的url条目, 未指定的url仍需授权后才能访问
        String[] split = urlMatchers.split(",");
        http.authorizeRequests()
            //下边的路径放行
            .antMatchers(split).permitAll()
            .anyRequest().authenticated();
    }
}

appliaction.yml 中配置 oauth2.urlMatchers

oauth2:
  urlMatchers: /v2/api-docs,/swagger-resources/configuration/ui,/swagger-resources,/swagger-resources/configuration/security,/swagger-ui.html,/webjars/**,"/course/coursepic/get/*"

请求请求到如下连接,进行 查询课程图片信息

GET:http://localhost:50201/api/course/coursepic/list/4028e58161bd22e60161bd23672a0001

测试结果如下

image-20200531175205607

完整的路由配置

zuul:
  routes:
    xc-service-learning: #路由名称,名称任意,保持所有路由名称唯一
      path: /learning/**
      serviceId: xc-service-learning #指定服务id,从Eureka中找到服务的ip和端口
      strip-prefix: false
      sensitiveHeaders:
    manage-course:
      path: /course/**
      serviceId: xc-service-manage-course
      strip-prefix: false
      sensitiveHeaders:
    manage-cms:
      path: /cms/**
      serviceId: xc-service-manage-cms
      strip-prefix: false
    	sensitiveHeaders:
    manage-sys:
      path: /sys/**
      serviceId: xc-service-manage-cms
      strip-prefix: false
      sensitiveHeaders:
    service-ucenter:
    	path: /ucenter/**
      serviceId: xc-service-ucenter
      sensitiveHeaders:
      strip-prefix: false
    xc-service-manage-order:
      path: /order/**
      serviceId: xc-service-manage-order
      sensitiveHeaders:
      strip-prefix: false

5. 过滤器

Zuul的核心就是过虑器,通过过虑器实现请求过虑,身份校验等

配置ZuulFilter

自定义过虑器需要继承 ZuulFilter,ZuulFilter 是一个抽象类,需要覆盖它的四个方法,如下:

  • shouldFilter:返回一个 Boolean 值,判断该过滤器是否需要执行。返回true表示要执行此过虑器,否则不执
    行。
  • run:过滤器的业务逻辑。
  • filterType:返回字符串代表过滤器的类型,如下
    • pre:请求在被路由之前执行
    • routing:在路由请求时调用
    • post:在 routing 和 errror 过滤器之后调用
    • error:处理请求时发生错误调用
  • filterOrder:此方法返回整型数值,通过此数值来定义过滤器的执行顺序,数字越小优先级越高。

测试

过虑所有请求,判断头部信息是否有 Authorization,如果没有则拒绝访问,否则转发到微服务。

定义过虑器,使用 @Component 标识为 bean。

在网关工程下构建一个 filter 包,创建一个 LoginFilterTest 并继承于 ZuulFilter

public class LoginFilterTest extends ZuulFilter {
    private static final Logger LOG = LoggerFactory.getLogger(LoginFilterTest.class);
    @Override
    public String filterType() {
        return "pre";
    }
    @Override
    public int filterOrder() {
        return 2;//int值来定义过滤器的执行顺序,数值越小优先级越高
    }
    @Override
    public boolean shouldFilter() {// 该过滤器需要执行
        return true;
    }
    @Override
    public Object run() {
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletResponse response = requestContext.getResponse();         //获取响应对象
        HttpServletRequest request = requestContext.getRequest();           //获取请求对象
        //取出头部信息Authorization
        String authorization = request.getHeader("Authorization");
        //判断用户的请求中是否带有 Authorization 字段,如果没有则表示未认证用户
        if(StringUtils.isEmpty(authorization)){
            requestContext.setSendZuulResponse(false);// 拒绝访问
            requestContext.setResponseStatusCode(200);// 设置响应状态码
            ResponseResult unauthenticated = new ResponseResult(CommonCode.UNAUTHENTICATED);
            String jsonString = JSON.toJSONString(unauthenticated);
            requestContext.setResponseBody(jsonString);
            requestContext.getResponse().setContentType("application/json;charset=UTF-8");
            return null;
        }
        return null;
    }
}

测试请求:

http://localhost:50201/api/course/coursebase/get/4028e581617f945f01617f9dabc40000 查询课程信息

1、Header 中不设置 Authorization

响应结果:

image-20200531184006794

2、Header 中设置 Authorization

成功响应课程信息。

image-20200531183942639

五、身份校验

1. 需求分析

本小节实现网关连接 Redis 校验令牌:

1、从 cookie 查询用户身份令牌是否存在,不存在则拒绝访问

2、从 http header 查询jwt令牌是否存在,不存在则拒绝访问

3、从 Redis 查询 user_token 令牌是否过期,过期则拒绝访问

2. 业务实现

1、配置 application.yml

配置 redis链接参数:

spring:
  application:
    name: xc‐govern‐gateway
  redis:
    host: ${REDIS_HOST:127.0.0.1}
    port: ${REDIS_PORT:6379}
    timeout: 5000 #连接超时 毫秒
    jedis:
      pool:
        maxActive: 3
        maxIdle: 3
        minIdle: 1
        maxWait: ‐1  #连接池最大等行时间 ‐1没有限制

2、使用 StringRedisTemplate 查询 key 的有效期

service 包下定义 AuthService 类:

@Service
public class AuthService {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    //查询身份令牌
    public String getTokenFromCookie(HttpServletRequest request){
        Map<String, String> cookieMap = CookieUtil.readCookie(request, "uid");
        String access_token = cookieMap.get("uid");
        if(StringUtils.isEmpty(access_token)){
            return null;
        } 
        return access_token;
    } 
    //从header中查询jwt令牌
    public String getJwtFromHeader(HttpServletRequest request){
        String authorization = request.getHeader("Authorization");
        if(StringUtils.isEmpty(authorization)){
            //拒绝访问
            return null;
        }
        if(!authorization.startsWith("Bearer ")){
            //拒绝访问
            return null;
        } 
        return authorization;
    } 
    //查询令牌的有效期
        public long getExpire(String access_token) {
        //token在redis中的key
        String key = "user_token:"+access_token;
        Long expire = stringRedisTemplate.getExpire(key);
        return expire;
    }
}

3、定义LoginFilter

/**
 * 身份校验器
 * @author Mr.JK
 * @create 2020-08-28  13:14
 */
@Component
public class LoginFilter extends ZuulFilter {

    @Autowired
    AuthService authService;

    @Override
    public String filterType() {
        return "pre";
    }
    @Override
    public int filterOrder() {
        return 0;//int值来定义过滤器的执行顺序,数值越小优先级越高
    }
    @Override
    public boolean shouldFilter() {// 该过滤器需要执行
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        RequestContext requestContext = RequestContext.getCurrentContext();
        HttpServletResponse response = requestContext.getResponse();         //获取响应对象
        HttpServletRequest request = requestContext.getRequest();           //获取请求对象
        //取cookie中的身份令牌
        String tokenFromCookie = authService.getTokenFromCookie(request);
        if (StringUtils.isEmpty(tokenFromCookie)){
            //拒绝访问
            this.access_denied();
        }
        //从header中取jwt
        String jwtFromHeader = authService.getJwtFromHeader(request);
        if (StringUtils.isEmpty(jwtFromHeader)){
            //拒绝访问
            this.access_denied();
        }
        //从redis取出jwt的过期时间
        long expire = authService.getExpire(tokenFromCookie);
        if (expire < 0){
            //拒绝访问
            this.access_denied();
        }


        return null;
    }

    //拒绝访问
    private void access_denied(){
        RequestContext requestContext = RequestContext.getCurrentContext();

        requestContext.setSendZuulResponse(false);// 拒绝访问
        requestContext.setResponseStatusCode(200);// 设置响应状态码
        ResponseResult unauthenticated = new ResponseResult(CommonCode.UNAUTHENTICATED);
        String jsonString = JSON.toJSONString(unauthenticated);
        requestContext.setResponseBody(jsonString);
        requestContext.getResponse().setContentType("application/json;charset=UTF-8");
    }
}

3. 测试

1、配置代理

通过 nginx 转发到 gateway,在 www.xuecheng.com 虚拟主机来配置

#微服务网关
upstream api_server_pool{
	server 127.0.0.1:50201 weight=10;
} 
#微服务网关
location /api {
	proxy_pass http://api_server_pool;
}

使用 postman 测试:

GET 请求:http://www.xuecheng.com/api/course/coursepic/get/4028e581617f945f01617f9dabc40000

注意:这里通过网关请求了 course/coursepic/get 地址,课程管理 url 根据自己的开发情况去配置放行。

正常流程测试

1、执行登录使之向 cookie 写入身份令牌 uid

Post请求:http://ucenter.xuecheng.com/openapi/auth/userlogin

image-20200531190540813

并从redis获取jwt令牌的内容

image-20200531182147472

2、手动在postman添加header

image-20200531201049165

成功查询:

image-20200531201107131

这里要注意的是,如果这里出现 token验证失败,那就是你的课程管理管理服务的 resources 下的公钥文件于认证服务的私钥不匹配

异常流程测试

手动删除 header 或清除 cookie 观察测试结果。

image-20200531201145872