尚品汇商城


单点登录

所在位置:
11 单点登录 - 图1

一、单点登录业务介绍

早期单一服务器,用户认证。
11 单点登录 - 图2
缺点:单点性能压力,无法扩展

分布式,SSO(single sign on)模式
11 单点登录 - 图3
解决 :
用户身份信息独立管理,更好的分布式管理。
可以自己扩展安全策略
跨域不是问题

缺点:
认证服务器访问压力较大。

业务流程图{用户访问业务时,必须登录的流程}{单点登录的过程}
11 单点登录 - 图4

二、认证中心模块

2.1 实现思路

1、 用接收的用户名密码核对后台数据库
2、 核对通过,用uuid生成token
3、 将用户id加载到写入redis,redis的key为token,value为用户id。
4、 登录成功返回token与用户信息,将token与用户信息记录到cookie里面
5、 重定向用户到之前的来源地址。

数据库表:user_info,并添加一条数据!密码应该是加密的!

2.2 搭建认证中心模块service-user

2.2.1 搭建service-user服务

搭建方式如service-item

2.2.2 修改配置pom.xml

<?xml version=”1.0” encoding=”UTF-8”?>
<project xmlns=”http://maven.apache.org/POM/4.0.0“ xmlns:xsi=”http://www.w3.org/2001/XMLSchema-instance
xsi:schemaLocation=”http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd”
> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.atguigu.gmall</groupId> <artifactId>service</artifactId> <version>1.0</version> </parent>
<version>1.0</version> <artifactId>service-user</artifactId> <packaging>jar</packaging> <name>service-user</name> <description>service-user</description>
<build> <finalName>service-user</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
</project>

2.2.3 添加配置文件

bootstrap.properties

spring.application.name=service-user
spring.profiles.active
=dev
spring.cloud.nacos.discovery.server-addr
=192.168.200.129:8848
spring.cloud.nacos.config.server-addr
=192.168.200.129:8848
spring.cloud.nacos.config.prefix
=${spring.application.name}
spring.cloud.nacos.config.file-extension
=yaml
spring.cloud.nacos.config.shared-configs[0].data-id
=common.yaml


启动类

package com.atguigu.gmall.user;


@SpringBootApplication
@ComponentScan({“com.atguigu.gmall”})
@EnableDiscoveryClient
public class ServiceUserApplication {

public static void main(String[] args) {
SpringApplication.run(ServiceUserApplication.class, args);
}

}


2.2.4 封装登录接口

2.2.4.1 编写接口

package com.atguigu.gmall.user.service;


public interface UserService {

/
登录方法
@param **
userInfo
__
_ @return
**
**
/
_UserInfo login(UserInfo userInfo);

}

2.2.4.2 Mapper

UserInfoMapperpackage com.atguigu.gmall.user.mapper;

import com.atguigu.gmall.model.user.UserInfo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserInfoMapper extends BaseMapper {
}

2.2.4.3 实现类

package com.atguigu.gmall.user.service.impl;


@Service
public class UserServiceImpl implements UserService {

// 调用mapper 层 @Autowired
private UserInfoMapper userInfoMapper;


@Override
public UserInfo login(UserInfo userInfo) {
// select * from userInfo where userName = ? and passwd = ?
// 注意密码是加密: _String passwd = userInfo.getPasswd();
//123
// 将passwd 进行加密 String newPasswd = DigestUtils._md5DigestAsHex(passwd.getBytes());

QueryWrapper queryWrapper = new QueryWrapper<>();
queryWrapper.eq(“login_name”, userInfo.getLoginName());
queryWrapper.eq(“passwd”, newPasswd);
UserInfo info = userInfoMapper.selectOne(queryWrapper);
if (info != null) {
return info;
}
return null;
}
}

2.2.4.4 控制器

package com.atguigu.gmall.user.controller;
/*

用户认证接口

*/
@RestController
@RequestMapping(“/api/user/passport”)
public class PassportApiController {

@Autowired
private UserService userService;

@Autowired
private RedisTemplate redisTemplate;

/
登录
@param **
userInfo
__
*@param request
__
*@param response
__
@return
**
**
/
@PostMapping(“login”)
public Result login(@RequestBody UserInfo userInfo, HttpServletRequest request, HttpServletResponse response) {
System.out.println(“进入控制器!”);
UserInfo info = userService.login(userInfo);

if (info != null) {
String token = UUID.randomUUID().toString().replaceAll(“-“, “”);
HashMap map = new HashMap<>();
map.put(“nickName”, info.getNickName());
map.put(“token”, token);

JSONObject userJson = new JSONObject();
userJson.put(“userId”, info.getId().toString());
userJson.put(“ip”, IpUtil.getIpAddress(request));
redisTemplate.opsForValue().set(RedisConst.USER_LOGIN_KEY_PREFIX + token, userJson.toJSONString(), RedisConst.USERKEY_TIMEOUT, TimeUnit.SECONDS);
return Result.ok(map);
} else {
return Result.fail().message(“用户名或密码错误”);
}
}

/
退出登录
@param **
request
__
@return
**
**
/
@GetMapping(“logout”)
public Result logout(HttpServletRequest request){
redisTemplate.delete(RedisConst.USER_LOGIN_KEY_PREFIX + request.getHeader(“token”));
return Result.ok();
}
}

2.3 在web-all模块添加实现

2.3.1 配置网关server-gateway

- id: service-user
uri: lb://service-user
predicates:
- Path=//user/
-
id: web-passport
uri: lb://web-all
predicates*
:
- Host=passport.gmall.com

2.3.2 在web-all 项目中跳转页面

package com.atguigu.gmall.all.controller;

/*

用户认证接口


/
@Controller
public class PassportController {

/

@return
* /
@GetMapping(“login.html”)
public String login(HttpServletRequest request) {
String originUrl = request.getParameter(“originUrl”);
request.setAttribute(“originUrl”,originUrl);
return “login”;
}

}

2.3.3 登录页面

页面资源: \templates\login1.html
login1.html 没有公共头部信息
login.html 有公共头部信息
11 单点登录 - 图5
Html关键代码
<form class=”sui-form”>
<div class=”input-prepend”><span class=”add-on loginname”></span>
<input id=”inputName” type=”text” v-model=”user.loginName” placeholder=”邮箱/用户名/手机号” class=”span2 input-xfat”>
</div>
<div class=”input-prepend”><span class=”add-on loginpwd”></span>
<input id=”inputPassword” type=”password” v-model=”user.passwd” placeholder=”请输入密码” class=”span2 input-xfat”>
</div>
<div class=”setting”>
<label class=”checkbox inline”>
<input name=”m1” type=”checkbox” value=”2” checked=””>
自动登录
</label>
<span class=”forget”>忘记密码?</span>
</div>
<div class=”logined”>
<a class=”sui-btn btn-block btn-xlarge btn-danger” href=”javascript:” @click=”submitLogin()”>登  录</a>
</div>
</form>

<script src=”/js/api/login.js”></script>
<script th:inline=”javascript”>
var item = new Vue({
el: ‘#profile’,

  1. **data**: {<br /> **originUrl**: [[${originUrl}]],<br /> **user**: {<br /> **loginName**: **''**,<br /> **passwd**: **''**<br /> }<br /> },
  2. created() {<br /> },
  3. **methods**: {<br /> submitLogin() {<br /> **_login_**.login(**this**.**user**).then(response => {<br /> <br /> if (response.**data**.**code **== 200) {<br /> _//把token存在cookie中、也可以放在localStorage中_<br /> **_auth_**.setToken(response.**data**.**data**.**token**)<br /> **_auth_**.setUserInfo(**_JSON_**.stringify(response.**data**.**data**))
  4. **_console_**.log(**"originUrl:"**+**this**.**originUrl**);<br /> **if**(**this**.**originUrl **== **''**){<br /> **_window_**.**location**.**href**=**"http://www.gmall.com/index.html"**<br /> **return **;<br /> } **else **{<br /> **_window_**.**location**.**href **= _decodeURIComponent_(**this**.**originUrl**)<br /> }<br /> } **else **{<br /> _alert_(response.**data**.**data**.**message**)<br /> }
  5. })<br /> }<br /> }<br /> })<br /></**script**>

2.4 头部信息处理

web-all项目:common/header.html,common/head.html
功能:头部信息为公共信息,所有页面都具有相关的头部,所以我们可以单独提取出来,头部页面显示登录状态与关键字搜索等信息

2.4.1 提取头部信息

提取头部信息我们会用到thymeleaf 两个标签:
th:fragment:定义代码块
th:include:将代码块片段包含的内容插入到使用了th:include的HTML标签中
1,定义头部代码块(/common/header.html),关键代码
<div id=”nav-bottom” th:fragment=”header”>

<div class=”nav-top” id=”header”>


</div>
2,在其他页面引用头部代码块
<div th:include=”common/header :: header”></div>

2.4.2 头部登录状态处理

思路:登录成功后我们将用户信息写入了cookie,所以我们判断cookie中是否有用户信息,如果有则显示登录用户信息和退出按钮,我们采取vue的渲染方式
关键代码
Header.html 中
<ul class=”fl”>
<li class=”f-item”>尚品汇欢迎您!</li>
<li v-if=”userInfo.nickName == ‘’” class=”f-item”>请<span><a href=”javascript:” @click=”login()”>登录</a></span> <span><a href=”#”>免费注册</a></span></li>
<li v-if=”userInfo.nickName != ‘’” class=”f-item”><span>{{userInfo.nickName}}</span> <span><a href=”javascript:” @click=”logout()”>退出</a></span></li>
</ul>

<script type=”text/javascript” src=”/js/plugins/jquery/jquery.min.js”></script>
<script type=”text/javascript” src=”/js/plugins/jquery.cookie.js”></script>
<script src=”/js/plugins/vue.js”></script>
<script src=”/js/plugins/axios.js”></script>
<script src=”/js/auth.js”></script>
<script src=”/js/request.js”></script>
<script src=”/js/api/login.js”></script>
<script th:inline=”javascript”>
var item = new Vue({
el: ‘#header’,

    **data**: {<br />            **userInfo**: {<br />                **nickName**: **''**,<br />                **name**: **''**<br />            }<br />        },

    created() {<br />            **this**.showInfo()<br />        },

    **methods**: {<br />            showInfo() {<br />                _// debugger_<br />                **if**(**_auth_**.getUserInfo()) {<br />                    **this**.**userInfo **= **_auth_**.getUserInfo()<br />                    **_console_**.log(**"--------"**+**this**.**userInfo**.**nickName**)<br />                }<br />            },

       <br />            logout() {<br />                _//debugger_<br />                **_login_**.logout().then(response => {<br />                    **_console_**.log(**"已退出"**)<br />                    **_auth_**.removeToken()<br />                    **_auth_**.removeUserInfo()

                _//跳转页面_<br />                    **_window_**.**location**.**href **= **"/"**<br />                })<br />            }<br />        }<br />    })<br /></**script**>

2.4.3 头部关键字搜索

<div class=”input-append”>
<input id=”keyword” type=”text” v-model=”keyword” class=”input-error input-xxlarge” />
<button class=”sui-btn btn-xlarge btn-danger” @click=”search()” type=”button”>搜索</button>
</div>
<script th:inline=”javascript”>
var item = new Vue({
el: ‘#header’,

    **data**: {<br />            **keyword**: [[${searchParam?.keyword}]],<br />            **userInfo**: {<br />                **nickName**: **''**,<br />                **name**: **''**<br />            }<br />        },

    created() {<br />            **this**.showInfo()<br />        },

    **methods**: {<br />            showInfo() {<br />                _// debugger_<br />                **if**(**_auth_**.getUserInfo()) {<br />                    **this**.**userInfo **= **_auth_**.getUserInfo()<br />                    **_console_**.log(**"--------"**+**this**.**userInfo**.**nickName**)<br />                }<br />            },

        search() {<br />                **if**(**this**.**keyword **== **null**) **this**.**keyword **= **''**<br />                **_window_**.**location**.**href **= **'http://list.gmall.com/search.html?keyword=' **+ **this**.**keyword**<br />            },

        login() {<br />                **_window_**.**location**.**href **= **'http://passport.gmall.com/login.html?originUrl='**+**_window_**.**location**.**href**<br />            },

        logout() {<br />                _//debugger_<br />                **_login_**.logout().then(response => {<br />                    **_console_**.log(**"已退出"**)<br />                    **_auth_**.removeToken()<br />                    **_auth_**.removeUserInfo()

                _//跳转页面_<br />                    **_window_**.**location**.**href **= **"/"**<br />                })<br />            }<br />        }<br />    })<br /></**script**><br />说明:[[${searchParam?.keyword}]],searchParam为搜索列表的搜索对象,如果存在searchParam对象,显示关键字的值

2.4.4 头部公共js

<!DOCTYPE html> <html xmlns:th=”http://www.thymeleaf.org> <head> <meta http-equiv=”Content-Type” content=”text/html; charset=UTF-8” /> </head> <body>
<div th:fragment=”head”> <script type=”text/javascript” src=”/js/plugins/jquery/jquery.min.js”></script> <script type=”text/javascript” src=”/js/plugins/jquery.cookie.js”></script> <script src=”/js/plugins/vue.js”></script> <script src=”/js/plugins/axios.js”></script> <script src=”/js/auth.js”></script> <script src=”/js/request.js”></script> </div> </body> </html>


引用
11 单点登录 - 图6

三、用户认证与服务网关整合

3.1 实现思路

  1. 所有请求都会经过服务网关,服务网关对外暴露服务,不管是api异步请求还是web同步请求都走网关,在网关进行统一用户认证
    2. 既然要在网关进行用户认证,网关得知道对哪些url进行认证,所以我们得对url制定规则
    3. Web页面同请求(如:.html),我采取配置白名单的形式,凡是配置在白名单里面的请求都是需要用户认证的(注:也可以采取域名的形式,方式多多)
    4. Api接口异步请求的,我们采取url规则匹配,如:/api/*/auth/
    ,如凡是满足该规则的都必须用户认证
    在网关添加redis相关配置!
redis:
host: 192.168.200.129
port: 6379
database: 0
timeout: 1800000
password:

authUrls:
url: trade.html,myOrder.html,list.html
pom.xml

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId></dependency>
在网关中添加redis的配置类。注意需要引入RedisConfig配置类

3.2 在服务网关添加fillter

server-gateway 项目中添加一个过滤器

| package com.atguigu.gmall.gateway.filter;

@Componentpublic class AuthGlobalFilter implements GlobalFilter{

@Autowired<br />    **private **RedisTemplate **redisTemplate**;

_// 匹配路径的工具类<br />      _**private **AntPathMatcher **antPathMatcher **= **new **AntPathMatcher();

@Value(**"${authUrls.url}"**)<br />    **private **String **authUrls**;<br />    @Override<br />    **public **Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {<br />        _// 获取到请求对象<br />        _ServerHttpRequest request = exchange.getRequest();<br />        _// 获取Url<br />        _String path = request.getURI().getPath();<br />        _// 如果是内部接口,则网关拦截不允许外部访问!<br />        _**if **(**antPathMatcher**.match(**"/**/inner/**"**,path)){<br />            ServerHttpResponse response = exchange.getResponse();<br />            **return **out(response,ResultCodeEnum.**_PERMISSION_**);<br />        }<br />        _// 获取用户Id<br />        _String userId = getUserId(request);       _//token被盗用       _**if**(**"-1"**.equals(userId)) {<br />          ServerHttpResponse response = exchange.getResponse();<br />          **return **out(response,ResultCodeEnum.**_PERMISSION_**);<br />       }        _// 用户登录认证<br />        //api接口,异步请求,校验用户必须登录<br />        _**if**(**antPathMatcher**.match(**"/api/**/auth/**"**, path)) {<br />            **if**(StringUtils._isEmpty_(userId)) {<br />                ServerHttpResponse response = exchange.getResponse();<br />                **return **out(response,ResultCodeEnum.**_LOGIN_AUTH_**);<br />            }<br />        }<br />        _// 验证url<br />        _**for **(String authUrl : **authUrls**.split(**","**)) {<br />            _// 当前的url包含登录的控制器域名,但是用户Id 为空!<br />            _**if **(path.indexOf(authUrl)!=-1 && StringUtils._isEmpty_(userId)){<br />                ServerHttpResponse response = exchange.getResponse();<br />                _//303状态码表示由于请求对应的资源存在着另一个URI,应使用重定向获取请求的资源<br />                _response.setStatusCode(HttpStatus.**_SEE_OTHER_**);<br />                response.getHeaders().set(HttpHeaders.**_LOCATION_**,**"http://www.gmall.com/login.html?originUrl="**+request.getURI());<br />                _// 重定向到登录<br />                _**return **response.setComplete();<br />            }<br />        }

    _// 将userId 传递给后端<br />        _**if **(!StringUtils._isEmpty_(userId)){<br />            request.mutate().header(**"userId"**,userId).build();<br />            _// 将现在的request 变成 exchange对象<br />            _**return **chain.filter(exchange.mutate().request(request).build());<br />        }<br />        **return **chain.filter(exchange);<br />    } |

| —- | | 工具类中 AuthContextHolder 有获取用户Id 的方法!
/
获取当前登录用户id
@param **
request
__
@return
**
**
/
public static String getUserId(HttpServletRequest request) {
String userId = request.getHeader(“userId”);
return StringUtils.isEmpty(userId) ? “” : userId;
}
|

3.3 在服务网关中判断用户登录状态

在网关中如何获取用户信息:
1,从cookie中获取(如:web同步请求)
2,从header头信息中获取(如:异步请求)
如何判断用户信息合法:
登录时我们返回用户token,在服务网关中获取到token后,我在到redis中去查看用户id,如果用户id存在,则token合法,否则不合法,同时校验ip,防止token被盗用。

3.3.1 取用户信息

/
获取当前登录用户id
@param **
request
__
@return
**
**
/
private String getUserId(ServerHttpRequest request) {
String token = “”;
List tokenList = request.getHeaders().get(“token”);
if(null != tokenList) {
token = tokenList.get(0);
} else {
MultiValueMap cookieMultiValueMap = request.getCookies();
HttpCookie cookie = cookieMultiValueMap.getFirst(“token”);
if(cookie != null){
token = URLDecoder.decode(cookie.getValue());
}
}
if(!StringUtils.isEmpty(token)) {
String userStr = (String)redisTemplate.opsForValue().get(“user:login:” + token);
JSONObject userJson = JSONObject.parseObject(userStr);
String ip = userJson.getString(“ip”);
String curIp = IpUtil.getGatwayIpAddress(request);
//校验token是否被盗用 if(ip.equals(curIp)) {
return userJson.getString(“userId”);
} else {
//ip不一致 return “-1”;
}
}
return “”;
}

3.3.2 输入信息out 方法

// 接口鉴权失败返回数据
private Mono out(ServerHttpResponse response,ResultCodeEnum resultCodeEnum) {
// 返回用户没有权限登录
_Result result = Result._build(null, resultCodeEnum);
byte[] bits = JSONObject.toJSONString(result).getBytes(StandardCharsets.UTF_8);
DataBuffer wrap = response.bufferFactory().wrap(bits);
response.getHeaders().add(“Content-Type”, “application/json;charset=UTF-8”);
// 输入到页面
return response.writeWith(Mono.just(wrap));
}

3.3.3 测试

1.通过网关访问内部接口,则不能访问!
http://localhost/api/product/inner/getSkuInfo/17
11 单点登录 - 图7
2. 测试登录权限
测试一:
未登录 :http://localhost/api/product/auth/hello
11 单点登录 - 图8
登录完成之后继续测试!
登录:http://localhost/api/product/auth/hello
11 单点登录 - 图9
使用localhost访问,你登录或者不登录,都会提示未登录!
测试二:
用户在未登录情况下测试:
http://item.gmall.com/api/product/auth/hello
11 单点登录 - 图10
在上面的访问链接的时候,如果用户登录了,那么还会继续提示未登录!
11 单点登录 - 图11
404 表示资源没有!没有提示未登录!
原因:
测试一:访问资源的时候,没有获取到userId
测试二:访问资源的时候,获取到了userId

因为:我们登录成功的时候,将token放入了cookie中。在放入cookie的时候,我们给cookie 设置了一个作用域。
return $.cookie(‘token’, token, {domain: ‘gmall.com’, expires: 7, path: ‘/‘})
测试一:使用的域名是localhost,测试二:使用item.gmall.com 包含gmall.com
所以测试二是正确的!以后我们访问的时候,不会通过localhost访问,都是通过域名访问的!
3. 验证Url 访问的是控制器
未登录直接访问:会弹出登录页面
http://list.gmall.com/list.html
4. 登录之后,然后在访问
会显示查询结果!
http://list.gmall.com/list.html