目录
单系统登录机制
HTTP无状态协议
Web应用采用的是 B/S 架构,也即 浏览器/服务器 架构,并且将HTTP作为浏览器与服务器之间的通信协议。但是HTTP是一种无状态协议,浏览器的每一次请求。服务器都会独立处理,不会与之前或者是之后的请求产生关联。如下图所示,三次请求/响应对之间没有任何联系。
相当于服务器就像是得了失忆症一样,把浏览器的每一次请求都看成是第一次。 但这也同时意味着——任何用户都能通过浏览器访问服务器资源,如果想保护服务器的某些资源,必须限制浏览器的请求;但要限制浏览器请求,就必须得鉴别浏览器请求(响应合法请求,忽略非法请求);但要鉴别浏览器请求,就必须得先清楚浏览器的请求状态。但是上面也说了,http协议是无状态的,既然如此,那就需要让服务器和浏览器共同维护一个状态——即会话机制。
Cookie会话机制
会话机制:浏览器第一次请求服务器,服务器会创建一个会话,并将会话的id作为响应的一部分发送给浏览器,浏览器接收到后就会存储这个会话id,并且在后续的请求中自动带上这个会话id,服务器接收到请求之后,会根据请求中的会话id来识别是不是同一个会话,是同一个会话的话就可以直接访问,否则的话可能会限制访问(如购物系统可能就会跳转到登录界面,以此获得会话id),即后续的请求与第一次请求产生了关联。
逻辑是这样没错,但是有一个问题——服务器可以在内存中保存这个会话对象,但是浏览器怎么保存会话id呢?
一种方案是将会话id作为每一个请求的参数,服务器接收到请求自然就能解析参数获得会话id,并借此来判断是否来自同一个会话, 很明显这种方式不靠谱(每次都要添加会话id作为请求参数,用户会添加吗?)。
另一种方案就是上面的改进版了,既然用户不会添加,那浏览器就好人做到底,让浏览器自己来维护这个会话id,浏览器在每次发送http请求时都会自动发送会话id,要实现自动发送,这就要借助cookie机制了。cookie是浏览器用来存储少量数据的一种机制,数据以 key-value 形式存储,浏览器在发送http请求时会自动附带cookie信息,所以我们就可以用cookie来存储会话id。其实Tomcat会话机制也实现了cookie,访问Tomcat服务器时,浏览器中可以看到一个名为“JSESSIONID”的cookie,这就是Tomcat会话机制维护的会话id。使用了cookie机制的请求响应过程如下图所示:
登录状态
有了会话机制,用户的登录状态就可以迎刃而解了,我们可以根据会话id来判断用户的登录状态。我们假设浏览器第一次请求服务器需要输入用户名与密码来验证身份(如登录界面),服务器拿到用户名密码去数据库比对,正确的话会创建一个会话id,并将这个会话标记为“已登录”之类的状态,并将会话状态保存在会话对象中,后续浏览器再次请求服务器的时候,服务器会根据会话id查看当前的会话状态。tomcat在会话对象中设置登录状态和查看登录状态如下所示:
设置登录状态:
HttpSession session = request.getSession();
session.setAttribute("isLogin", true);
查看登录状态:
HttpSession session = request.getSession();
session.getAttribute("isLogin");
每次请求受保护资源时都会检查会话对象中的登录状态,只有 isLogin=true 的会话才能访问,登录机制因此而实现。
多系统登录难点
Web系统早已从久远的单系统发展成为如今由多系统组成的应用群。面对如此众多的系统,并且每个系统都有可能要求用户进行登录,但是这样的话,用户难道要一个一个登录、然后一个一个注销吗?就像下图描述的这样:
面对由多系统组成的应用群,系统的复杂性应该由系统自身承担,而不是用户(不能降低用户的体验)。因为对于用户而言,用户才不管你的web系统内部多么复杂,在用户眼里都是一个统一的整体,也就是说,用户访问web系统的整个应用群与访问单个系统是一样的,只需要登录或者是注销一次就够了。
可能有人会想,既然单系统的登录可以使用会话机制,那多系统不可以吗?答案确实是不可以,对于多系统应用群会话机制就不再适用了。这是因为单系统登录的解决方案的核心是cookie,cookie携带会话id在浏览器与服务器之间维护会话状态。但是有一个问题值得注意:cookie是有作用域限制的,也就是cookie的域(通常是对应网站的域名),浏览器发送http请求时会自动携带与该域匹配的cookie,而不是所有的cookie。
可能又有人说,既然如此,那我们就把web应用群中所有子系统的域名统一放在一个顶级域名下,例如“*.baidu.com”,然后将它们的cookie域设置为“baidu.com”,这种做法理论上是可以的,甚至早期很多多系统登录就采用这种同域名共享cookie的方式。但是呢,可行是可行,但是这种共享cookie的方式存在众多局限。首先咱们的应用群的顶级域名得统一;其次,应用群各系统使用的技术(至少是web服务器)要相同,不然cookie的key值(Tomcat为 JSESSIONID)不同,就无法维持会话,因为共享cookie的方式是无法实现跨语言技术平台登录的,比如Java、PHP、.net系统之间;最后,cookie本身也不安全。
因此,我们需要一种全新的登录方式来实现多系统应用群的登录,这就是单点登录。
单点登录系统
单点登录,英文是 Single Sign On(缩写为 SSO)。是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录。即多个站点共用一台认证授权服务器,这样用户在任何一个站点登录后,就可以避免再次登录才能访问其它的站点。并且各站点间可以通过该登录状态直接交互。
单点登录流程
相比于单系统登录,sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。这个过程,也就是单点登录的原理,用下图说明:
流程描述:
- 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户未登录,将用户引导至登录页面
- 用户输入用户名密码提交登录申请
- sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
- sso认证中心带着令牌跳转会最初的请求地址(系统1)
- 系统1拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统1
- 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
- 用户访问系统2的受保护资源
- 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
- 系统2拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统2
- 系统2使用该令牌创建与用户的局部会话,返回受保护资源
用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心,全局会话与局部会话有如下约束关系:
- 局部会话存在,全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话必须销毁
单点注销流程
有单点登录自然就有单点注销,在一个子系统中注销,所有子系统的会话都将被销毁,用下面的流程图来说明:
sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作 。
流程描述:
- 用户向系统1发起注销请求
- 系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求
- sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
- sso认证中心向所有注册系统发起注销请求
- 各注册系统接收sso认证中心的注销请求,销毁局部会话
- sso认证中心引导用户至登录页面
部署图
单点登录涉及sso认证中心与众子系统,子系统与sso认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须集成sso的客户端(浏览器在整个过程中从未获取过令牌,都是浏览器请求的子系统获得令牌,然后子系统进而和sso认证中心进行交互,模拟客户端与服务器交互的过程),sso认证中心则是sso服务端,整个单点登录过程实质是sso客户端与服务端通信的过程,用下图描述:
sso认证中心与sso客户端通信方式有多种,这里以简单好用的httpClient为例,web service、rpc、restful api都可以。
子系统与sso认证中心的功能
sso单点登录采用客户端/服务端架构,我们先看sso-client子系统与sso-server认证中心各自要实现的功能(sso认证中心=sso-server):
sso-client子系统功能:
- 拦截子系统未登录用户请求,跳转至sso认证中心
- 接收并存储sso认证中心发送的令牌
- 与sso-server通信,校验令牌的有效性
- 建立局部会话
- 拦截用户注销请求,向sso认证中心发送注销请求
- 接收sso认证中心发出的注销请求,销毁局部会话
sso-server认证中心功能:
- 验证用户的登录信息
- 创建全局会话
- 创建授权令牌
- 与sso-client通信发送令牌
- 校验sso-client令牌有效性
- 系统注册
- 接收sso-client注销请求,注销所有会话
准备工作
项目结构
修改网关配置文件
在原网关配置中添加如下配置:
- id: router02
uri: lb://sca-auth
predicates:
#- Path=/auth/login/** #刚开始时这里必须是login,因为是默认的,后续可以更改
- Path=/auth/oauth/** #微服务架构(更改之后),需要令牌
filters:
- StripPrefix=1
现网关配置文件内容:
server:
port: 9000
spring:
application:
name: sca-resource-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
config:
server-addr: localhost:8848
file-extension: yml
gateway:
discovery:
locator:
enabled: true
routes:
- id: router01
uri: lb://sca-resource
predicates:
- Path=/sca/resource/upload/**
filters:
- StripPrefix=1
- id: router02
uri: lb://sca-auth
predicates:
#- Path=/auth/login/** #刚开始时这里必须是login,因为是默认的,后续可以更改
- Path=/auth/oauth/** #微服务架构(更改之后),需要令牌
filters:
- StripPrefix=1
globalcors: #跨域配置
corsConfigurations:
'[/**]':
allowedOrigins: "*"
allowedHeaders: "*"
allowedMethods: "*"
allowCredentials: true
sentinel:
transport:
dashboard: localhost:8180
port: 8719
eager: true
添加相关依赖
在权限认证项目pom文件中添加如下依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
现 pom文件内容:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>02-sca-files</artifactId>
<groupId>com.jt</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>sca-auth</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
构建权限认证项目配置文件
在sca-auth工程中创建bootstrap.yml文件,内容如下:
server:
port: 8071
spring:
application:
name: sca-auth #定义nacos服务名称
cloud:
nacos:
discovery:
server-addr: localhost:8848
config:
server-addr: localhost:8848
前端UI设计
前端UI设计工程项目结构:
之所以有两个登录界面,是因为login.html是用于postman测试的,而login-sso.html里面定义了一些特定的参数,可以直接在浏览器直接输入地址访问.用户登录之后,可以进入到文件上传界面.文件上传也是同样的道理(fileupload-sso.html携带了headers头部信息).
各自的HTML内容如下:
1)fileupload.html文件内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文件上载演示</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
ul>li{
list-style-type: none;
}
</style>
</head>
<body>
<h1>文件上传案例演示:</h1>
<form id="fileForm" method="post"
enctype="multipart/form-data"
onsubmit="return doUpload()">
<div>
<ul>
<li><input id="uploadFile" type="file" name="uploadFile"></li>
<li><button type="submit">上传文件</button></li>
</ul>
</div>
</form>
</body>
<script>
//jquery代码的表单提交事件
function doUpload(){
debugger //debug窗口打开以后调试代码
//获得用户选中的所有图片(获得数组)
let files=document.getElementById("uploadFile").files;
if(files.length>0){
//获得用户选中的唯一文件(从数组中取出)
let file=files[0];
//开始上传这个文件
//由于上传代码比较多,不想和这里其它代码干扰,所以定义一个方法调用
upload(file);
}
//阻止表单提交效果
return false;
};
// 将file上传到服务器的方法
function upload(file){
//定义一个表单
let form=new FormData();
//将图片添加到表单中
form.append("uploadFile",file);
let url="http://localhost:9000/sca/resource/upload/";
//异步提交方式1
axios.post(url,form)
.then(function (response){
alert("upload ok")
console.log(response.data);
})
.catch(function (e){//失败时执行catch代码块
console.log(e);
})
//异步提交方式2
// axios({
// url:"http://localhost:8881/resource/upload/",
// method:"post",
// data:form
// }).then(function(response){
// alert("upload ok")
// console.log(response.data);
// })
}
</script>
</html>
2)fileupload-sso.html文件内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文件上载演示</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
ul>li{
list-style-type: none;
}
</style>
</head>
<body>
<h1>文件上传案例演示:</h1>
<form id="fileForm" method="post"
enctype="multipart/form-data"
onsubmit="return doUpload()">
<div>
<ul>
<li><input id="uploadFile" type="file" name="uploadFile"></li>
<li><button type="submit">上传文件</button></li>
</ul>
</div>
</form>
</body>
<script>
//jquery代码的表单提交事件
function doUpload(){
//debugger //debug窗口打开以后调试代码
//获得用户选中的所有图片(获得数组)
let files=document.getElementById("uploadFile").files;
if(files.length>0){
//获得用户选中的唯一文件(从数组中取出)
let file=files[0];
//开始上传这个文件
//由于上传代码比较多,不想和这里其它代码干扰,所以定义一个方法调用
upload(file);
}
//阻止表单提交效果
return false;
};
// 将file上传到服务器的方法
function upload(file){
//定义一个表单
let form=new FormData();
//将图片添加到表单中
form.append("uploadFile",file);
let url="http://localhost:9000/sca/resource/upload/";
//异步提交方式1
axios.post(url,form,{headers:{"Authorization":"Bearer "+localStorage.getItem("accessToken")}})
.then((response)=>{
debugger
console.log(response)
let result = response.data
if (result.state&&result.state==403){
alert(result.message)
return
}else if (result.state&&result.state==401){
alert(result.message)
return
}
alert("upload ok")
}
)
.catch(function (e){//失败时执行catch代码块
console.log(e);
})
}
</script>
</html>
3)login.html文件内容:
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<title>login</title>
</head>
<body>
<div class="container"id="app">
<h3>Please Login</h3>
<form>
<div class="mb-3">
<label for="usernameId" class="form-label">Username</label>
<input type="text" v-model="username" class="form-control" id="usernameId" aria-describedby="emailHelp">
</div>
<div class="mb-3">
<label for="passwordId" class="form-label">Password</label>
<input type="password" v-model="password" class="form-control" id="passwordId">
</div>
<button type="button" @click="doLogin()" class="btn btn-primary">Submit</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
var vm=new Vue({
el:"#app",//定义监控点,vue底层会基于此监控点在内存中构建dom树
data:{ //此对象中定义页面上要操作的数据
username:"",
password:""
},
methods: {//此位置定义所有业务事件处理函数
doLogin() {
//1.定义url
let url = "http://localhost:9000/auth/login"
//2.定义参数
let params = new URLSearchParams()
params.append('username',this.username);
params.append('password',this.password);
//3.发送异步请求
axios.post(url, params).then((response) => {
debugger
let result=response.data;
console.log(result);
if (result.state == 200) {
alert("login ok");
location.href="/fileupload.html"
} else {
alert(result.message);
}
})
}
}
});
</script>
</body>
</html>
4)login-sso.html文件内容:
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<title>login</title>
</head>
<body>
<div class="container"id="app">
<h3>Please Login</h3>
<form>
<div class="mb-3">
<label for="usernameId" class="form-label">Username</label>
<input type="text" v-model="username" class="form-control" id="usernameId" aria-describedby="emailHelp">
</div>
<div class="mb-3">
<label for="passwordId" class="form-label">Password</label>
<input type="password" v-model="password" class="form-control" id="passwordId">
</div>
<button type="button" @click="doLogin()" class="btn btn-primary">Submit</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
var vm=new Vue({
el:"#app",//定义监控点,vue底层会基于此监控点在内存中构建dom树
data:{ //此对象中定义页面上要操作的数据
username:"",
password:""
},
methods: {//此位置定义所有业务事件处理函数
doLogin() {
//1.定义url
//let url = "http://localhost:9000/auth/login"
let url = "http://localhost:9000/auth/oauth/token"
//2.定义参数
let params = new URLSearchParams()
params.append('username',this.username);
params.append('password',this.password);
params.append('client_id','gateway-client');
params.append('grant_type','password');
params.append('client_secret',"123456");
// debugger
//3.发送异步请求
axios.post(url, params).then((response) => {
debugger
console.log(response.data);
let result = response.data;
localStorage.setItem("accessToken",result.access_token);
location.href="/fileupload.html"
})
}
}
});
</script>
</body>
</html>
注意区分两个登录文件的区别. login-sso.html主要是已经实现了特定参数传递的功能.
代码实现
权限认证工程
项目结构
TokenConfig类
创建jwt令牌配置类,基于这个类实现令牌的创建和解析
package com.jt.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
/**
* 创建jwt令牌配置类,基于这个类实现令牌的创建和解析
* jwt令牌由3个部分构成
* 1)HEADER(头部信息:令牌信息)
* 2)PAYLOAD(数据信息-用户信息,权限信息)
* 3)SIGNATURE(签名信息-对header和payload部分进行加密)*/
@Configuration
public class TokenConfig {
//定义令牌签发口令:当客户端在执行登录时,假如有携带这个信息,认证服务器就可以给他签发一个令牌
//在对header和payload部分进行签名时,需要的一个口令
private String SIGNING_KEY = "auth";
//构建令牌生成器对象(构建和存储令牌)
@Bean
public TokenStore tokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}
//jwt转换器,将任何数据转换为jwt字符串令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
//设置加密/解密口令
converter.setSigningKey(SIGNING_KEY);
return converter;
}
}
SecurityConfig类
定义登录的规则(成功登录时的返回信息与登录失败时的返回信息),创建认证管理器对象
package com.jt.auth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//密码加密对象
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//在这个方法中定义登录规则
//1.对所有请求放行(当前工程只做认证)
//2.登陆成功信息的返回
//3.登录失败信息的返回
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭跨域攻击
http.csrf().disable();
//放行所有请求
http.authorizeRequests().anyRequest().permitAll();
//登录成功与失败的返回
http.formLogin()
.successHandler(successHandler())
.failureHandler(failureHandler());
}
@Bean
public AuthenticationSuccessHandler successHandler(){
return (request, response, authentication)->{
//1.构建map对象,封装响应数据
Map<String,Object> map = new HashMap<>();
map.put("state", 200);
map.put("message", "login ok");
//2.将对象转换为JSON,并写到客户端
writeJsonToClient(response, map);
};
}
@Bean
public AuthenticationFailureHandler failureHandler(){
return (request, response, authentication) -> {
//1.构建map对象,封装响应数据
Map<String,Object> map = new HashMap<>();
map.put("state", 500);
map.put("message", "login failure");
//2.将对象转换为JSON,并写到客户端
writeJsonToClient(response, map);
};
}
private void writeJsonToClient(HttpServletResponse response,Object object) throws IOException {
//2.将对象转换为JSON
//Gson-->toJson (需要自己找依赖)
//fastjson-->JSON (spring-cloud-starter-alibaba-sentinel)
//jackson-->writeValueAsString (spring-boot-starter-web)
String jsonStr = new ObjectMapper().writeValueAsString(object);
//3.将json字符串写到客户端
PrintWriter writer = response.getWriter();
writer.println(jsonStr);
writer.flush();
}
//创建认证管理器对象(此对象主要负责对客户端输入的用户信息进行认证),后面授权服务器会用到
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
UserDetailsServiceImpl类
登录时用户信息的获取和封装会在此对象进行实现.
package com.jt.auth.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 登录时用户信息的获取和封装会在此对象进行实现,
* 在页面点击登录按钮时会调用这个对象的loadUserByUsername方法,
* 页面上输入的用户名会传给这个方法的参数
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
//UserDetails用户封装用户信息(认证和权限信息)
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
//1.基于用户名查询用户信息(用户名,用户密码,用户状态...)
//Userinfo userinfo = userMapper.selectUserByUsername(username);
String encodePassword = passwordEncoder.encode("123456");
//2.查询用户权限信息,这里给的是假数据
List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList(//这里的权限信息先这么写,后面再讲其它
"sys:res:create", "sys:res:retrive");
//3.对用户信息进行封装
return new User(username, encodePassword, authorities);
}
}
Oauth2Config类
完成所有配置的组装,在这个配置类中完成认证授权,JWT令牌签发等配置操作.
package com.jt.auth.config;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import java.util.Arrays;
/**完成所有配置的组装,在这个配置类中完成认证授权,JWT令牌签发等配置操作*/
@AllArgsConstructor
@EnableAuthorizationServer//开启认证和授权服务
@Configuration
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {
//此对象负责完成认证管理
private AuthenticationManager authenticationManager;
//负责完成令牌的创建,信息读取等
private TokenStore tokenStore;
//负责获取用户的详情信息(username,password,client_id,grand_type,client_secret)
private ClientDetailsService clientDetailsService;
//jwt令牌转换器(基于用户信息构建令牌和解析令牌)
private JwtAccessTokenConverter jwtAccessTokenConverter;
//密码加密匹配器对象
private PasswordEncoder passwordEncoder;
//负责获取用户详细信息
private UserDetailsService userDetailsService;
/***/
//设置认证端点的配置(/oauth/token)
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//配置认证管理器
.authenticationManager(authenticationManager)
//验证用户的方法获得用户详情
.userDetailsService(userDetailsService)
//要求提交认证使用post请求方式,提高安全性
.allowedTokenEndpointRequestMethods(HttpMethod.POST,HttpMethod.GET)
//要配置令牌的生成,由于令牌生成比较复杂,下面有方法实现
.tokenServices(tokenService());//这个被注释的话,默认令牌为uuid的
}
//定义令牌生成策略
@Bean
public AuthorizationServerTokenServices tokenService(){
//这个方法的目标就是获得一个令牌生成器
DefaultTokenServices services=new DefaultTokenServices();
//支持令牌刷新策略(令牌有过期时间)
services.setSupportRefreshToken(true);
//设置令牌生成策略(tokenStore在TokenConfig配置了),使用的是jwt
services.setTokenStore(tokenStore);
//设置令牌增强(固定用法-令牌Payload部分允许添加扩展数据,例如用户权限信息)
TokenEnhancerChain chain=new TokenEnhancerChain();
chain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));
//令牌增强对象设置到令牌生成
services.setTokenEnhancer(chain);
//设置令牌有效期
services.setAccessTokenValiditySeconds(3600);//1小时
//刷新令牌应用场景:一般在用户登录系统后,令牌快过期时,系统自动帮助用户刷新令牌,提高用户的体验感
services.setRefreshTokenValiditySeconds(3600*72);//3天
//配置客户端详情
services.setClientDetailsService(clientDetailsService);
return services;
}
// 设置客户端详情类似于用户详情
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
//客户端id
.withClient("gateway-client")
//客户端秘钥
.secret(passwordEncoder.encode("123456"))
//设置权限
.scopes("all")//all只是个名字而已和写abc效果相同
//允许客户端进行的操作 里面的字符串千万不能写错
.authorizedGrantTypes("password","refresh_token");
}
// 认证成功后的安全约束配置
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//认证通过后,允许客户端进行哪些操作
security
//公开oauth/token_key端点
.tokenKeyAccess("permitAll()")
//公开oauth/check_token端点
.checkTokenAccess("permitAll()")
//允许提交请求进行认证(申请令牌)
.allowFormAuthenticationForClients();
}
}
资源访问工程
项目结构
RequiredLog注解
基于注解方式的Aop
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiredLog {
String value() default "";
}
LogAspect类
定义切面、切入点、通知
@Slf4j
@Aspect
@Component
public class LogAspect {
@Pointcut("@annotation(com.jt.resource.annotation.RequiredLog)")
public void doLog(){}
//可以不要上面的方法,使用下面一行直接搞定,但更难理解
// @Around("@annotation(com.jt.resource.annotation.RequiredLog")
@Around("doLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
log.debug("Before {}", System.currentTimeMillis());
Object result = joinPoint.proceed();
log.debug("After {}", System.currentTimeMillis());
return result;
}
}
CorsFilterConfig类
/**
* 基于过滤器做跨域配置
*/
//@Configuration
public class CorsFilterConfig {
//Spring MVC中注册过滤器使用FilterRegistrationBean对象
@Bean
public FilterRegistrationBean<CorsFilter> filterRegistrationBean(){
//1.构建基于Url方式的跨域配置对象
UrlBasedCorsConfigurationSource configSource=
new UrlBasedCorsConfigurationSource();
//2.构建url请求规则配置
CorsConfiguration config=new CorsConfiguration();
//2.1允许所有请求头跨域
config.addAllowedHeader("*");
//2.2允许所有请求方式(get,post,put,...)
config.addAllowedMethod("*");//get,post,
//2.3允许所有请求ip,port跨域
config.addAllowedOrigin("*");//http://ip:port
//2.4允许携带cookie进行跨域
config.setAllowCredentials(true);
//2.5将这个跨域配置应用到具体的url
configSource.registerCorsConfiguration("/**",config);
//3.注册过滤器
FilterRegistrationBean fBean=
new FilterRegistrationBean(
new CorsFilter(configSource));
//设置过滤器优先级
fBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return fBean;
}
}
ResourceServerConfig类
package com.jt.resource.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
/**
* @Author 作者:小龙猿
* @Project 项目:CGB2105-Step04
* @Time 时间:2021/8/30 10:43
*/
/**
* 资源服务器的配置,在这个对象中重点要实现:
* 1)jwt令牌解析的配置(客户端带着令牌访问资源时,要对令牌进行解析)
* 2)启动资源访问的授权配置(不是所有登录用户可以访问所有资源)
*/
@Configuration
@EnableResourceServer//此注解会启动资源服务器的默认配置
@EnableGlobalMethodSecurity(prePostEnabled = true)//执行方法之前启动权限检查
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
//super.configure(resources);
//定义令牌生成策略,这里不是要创建令牌,是要解析令牌
resources.tokenStore(tokenStore);
}
//设置访问资源时的一些规则
@Override
public void configure(HttpSecurity http) throws Exception {
//super.configure(http);
//1.关闭跨域攻击
http.csrf().disable();
//2.放行所有资源的访问(对资源的访问不做认证)
http.authorizeRequests().anyRequest().permitAll();//没有这个配置时上传文件会报401错误(没有授权Unauthorized)
//http.authorizeRequests().mvcMatchers("/resource/*").authenticated();//对访问指定路径的资源进行认证(比如逛淘宝,你可以访问任意商品,但要添加购物车时就会让你登录认证)
//3.处理异常(访问出错的时候应该友好的提示给用户,而不是报错)
//3.1认证异常(401)
// http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint());
//3.2授权异常(403)
http.exceptionHandling().accessDeniedHandler(accessDeniedHandler());
}
private AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, exception)->{
//1.构建响应数据
Map<String,Object> map = new HashMap<>();
map.put("state", HttpServletResponse.SC_UNAUTHORIZED);
map.put("message", "访问资源需要进行登录认证");
//2.将响应数据写到客户端
//2.1设置响应数据编码
response.setCharacterEncoding("utf-8");
//2.2告诉客户端要响应的数据类型,以及什么编码进行数据呈现
response.setContentType("application/json;charset=utf-8");
//2.3将数据转换为JSON
String jsonStr = new ObjectMapper().writeValueAsString(map);
//2.4将JSON写到客户端
PrintWriter writer = response.getWriter();
writer.println(jsonStr);
writer.flush();
};
}
private AccessDeniedHandler accessDeniedHandler() {
return (request, response, exception)->{
//1.构建响应数据
Map<String,Object> map = new HashMap<>();
map.put("state", HttpServletResponse.SC_FORBIDDEN);
map.put("message", "您没有访问该资源的权限");
//2.将响应数据写到客户端
//2.1设置响应数据编码
response.setCharacterEncoding("utf-8");
//2.2告诉客户端要响应的数据类型,以及什么编码进行数据呈现
response.setContentType("application/json;charset=utf-8");
//2.3将数据转换为JSON
String jsonStr = new ObjectMapper().writeValueAsString(map);
//2.4将JSON写到客户端
PrintWriter writer = response.getWriter();
writer.println(jsonStr);
writer.flush();
};
}
}
TokenConfig类
/**
* 创建jwt令牌配置类,基于这个类实现令牌的创建和解析
* jwt令牌由3个部分构成
* 1)HEADER(头部信息:令牌信息)
* 2)PAYLOAD(数据信息-用户信息,权限信息)
* 3)SIGNATURE(签名信息-对header和payload部分进行加密)*/
@Configuration
public class TokenConfig {
//定义令牌签发口令:当客户端在执行登录时,假如有携带这个信息,认证服务器就可以给他签发一个令牌
//在对header和payload部分进行签名时,需要的一个口令
private String SIGNING_KEY = "auth";
//构建令牌生成器对象(构建和存储令牌)
@Bean
public TokenStore tokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}
//jwt转换器,将任何数据转换为jwt字符串令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
//设置加密/解密口令
converter.setSigningKey(SIGNING_KEY);
return converter;
}
}
ResourceController类
package com.jt.resource.controller;
import com.jt.resource.annotation.RequiredLog;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 通过此对象实现文件上传服务
*/
@Slf4j
//@CrossOrigin
@RefreshScope
@RestController
@RequestMapping("/resource/")
public class ResourceController {
//当了类的上面添加了@Slf4J就不用自己创建下面的日志对象了
// private static final Logger log=
// LoggerFactory.getLogger(ResourceController.class);
@Value("${jt.resource.path}")
private String resourcePath;//="d:/uploads/";
@Value("${jt.resource.host}")
private String resourceHost;//="http://localhost:8881/";
//将@RequiredLog注解描述的方法作为切入点方法,在方法执行之前和之后进行日志记录
//PreAuthorize注解描述的方法表示必须已认证用户且有sys:res:create这个权限才可以访问
@PreAuthorize("hasAuthority('sys:res:create')")//设置访问时的权限,与权限认证里面的UserDetailsServiceImpl类设置的权限对应,不然不能访问
@RequiredLog("文件上传")
@PostMapping("/upload/")
public String uploadFile(MultipartFile uploadFile) throws IOException {
log.debug("开始上传文件");
//1.创建文件存储目录(按时间创建-yyyy/MM/dd)
//1.1获取当前时间的一个目录
String dateDir = DateTimeFormatter.ofPattern("yyyy/MM/dd")
.format(LocalDate.now());
//1.2构建目录文件对象
File uploadFileDir=new File(resourcePath,dateDir);
if(!uploadFileDir.exists()) uploadFileDir.mkdirs();
//2.给文件起个名字(尽量不重复)
//2.1获取原文件后缀
String originalFilename=uploadFile.getOriginalFilename();
String ext = originalFilename.substring(
originalFilename.lastIndexOf("."));
//2.2构建新的文件名
String newFilePrefix=UUID.randomUUID().toString();
String newFileName=newFilePrefix+ext;
//3.开始实现文件上传
//3.1构建新的文件对象,指向实际上传的文件最终地址
File file=new File(uploadFileDir,newFileName);
//3.2上传文件(向指定服务位置写文件数据)
uploadFile.transferTo(file);
String fileRealPath=resourceHost+dateDir+"/"+newFileName;
log.debug("fileRealPath {}",fileRealPath);
//后续可以将上传的文件信息写入到数据库?
return fileRealPath;
}
}
yml配置文件
server:
port: 8881
spring:
application:
name: sca-resource
cloud:
nacos:
config:
server-addr: localhost:8848
file-extension: yml
discovery:
server-addr: localhost:8848
# resources: #配置静态资源存储位置(默认resources/static目录),现在写到了配置中心
# static-locations: file:d:/uploads
# sentinel:
# eager: true #服务启动时就要与sentinel通讯,在sentinel控制台创建服务菜单
# transport:
# dashboard: localhost:8180
# port: 8719
#自定义上传的资源地址和服务器地址(写到了配置中心)
jt:
resource:
path: d:/uploads/
host: http://localhost:8881/
logging:
level:
com.jt: debug
测试阶段
Postman测试jwt令牌获取
先成功启动好nacos和sentinel.再进行postman测试.
在postman里先输入地址 http://localhost:9000/auth/oauth/token ,然后点击下面的 Params 以进行参数的配置(也可以直接在地址栏输入参数,但为了直观与减小错误率,建议不这么做).具体参数内容如下图:
测试成功后上图结果区域的内容如下:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzAwNjk3OTYsInVzZXJfbmFtZSI6ImphY2siLCJhdXRob3JpdGllcyI6WyJzeXM6cmVzOmNyZWF0ZSIsInN5czpyZXM6cmV0cml2ZSJdLCJqdGkiOiIwY2I5M2YyYS0xZjEzLTQxZDAtYmJmMi0xNGY4MDE2ZWNkYjUiLCJjbGllbnRfaWQiOiJnYXRld2F5LWNsaWVudCIsInNjb3BlIjpbImFsbCJdfQ.DUtdhGLSr-64JweJrOuxoPe-5mS62A1it--Z35_Rz0k",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJqYWNrIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6IjBjYjkzZjJhLTFmMTMtNDFkMC1iYmYyLTE0ZjgwMTZlY2RiNSIsImV4cCI6MTYzMDMyNTM5NiwiYXV0aG9yaXRpZXMiOlsic3lzOnJlczpjcmVhdGUiLCJzeXM6cmVzOnJldHJpdmUiXSwianRpIjoiYjRkMWVlZGItNDc1Mi00ODFmLTk2NTAtNjczOGI4YWYzZTVlIiwiY2xpZW50X2lkIjoiZ2F0ZXdheS1jbGllbnQifQ.fofDznmSjz2QuJmRZDBjc_NBdJPlFMD0a6rYaPUBLcY",
"expires_in": 3600,
"scope": "all",
"jti": "0cb93f2a-1f13-41d0-bbf2-14f8016ecdb5"
}
Postman测试文件上传
将上面的access_token的内容(即令牌)复制下来,访问 http://localhost:9000/sca/resource/upload/ 这个网址,先定义一下头部信息和Body信息,如下所示:
最后点击Send访问网址,查看结果:
浏览器测试登录流程
浏览器地址栏输入以下网址 http://localhost:8080/login-sso.html.
注意:这里也可以访问 http://localhost:8080/login.html,但是输入用户名和密码之后不能提交,因为需要的参数它还没有传递.
用户名和密码输入完成后,点击登录提交按钮,因为我们在login-sso.html里面设置了debugger,所以可以测试查看控制端输出的内容(必须先F12打开控制台才能进入到debugger阶段)
可以看到输出的结果和postman的结果是一样的.
登录成功后会切换到如下页面:
到这里,测试就已经成功了.
项目改进
登录的时候,用户名可以随意输入,密码为固定的 123456 ,还有设置用户的文件上传的权限也是写死了的,为了实现动态获取用户名和密码,对此项目需做改进.相应数据我们从数据库里面获取.我们可以专门创建一个项目工程,用于从数据库里面获取相应数据,然后以服务调用服务的方式获取数据.
数据库表设计
表的创建
DROP DATABASE IF EXISTS `jt-sso`;
CREATE DATABASE `jt-sso` DEFAULT CHARACTER SET utf8mb4;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
use `jt-sso`;
CREATE TABLE `tb_menus` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` varchar(50) NOT NULL COMMENT '权限名称',
`permission` varchar(200) DEFAULT NULL COMMENT '权限标识',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='权限表';
CREATE TABLE `tb_roles` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='角色表';
CREATE TABLE `tb_role_menus` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`role_id` bigint(11) DEFAULT NULL COMMENT '角色ID',
`menu_id` bigint(11) DEFAULT NULL COMMENT '权限ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='角色与权限关系表';
CREATE TABLE `tb_users` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) DEFAULT NULL COMMENT '密码',
`status` varchar(10) DEFAULT NULL COMMENT '状态 PROHIBIT:禁用 NORMAL:正常',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='系统用户表';
CREATE TABLE `tb_user_roles` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` bigint(11) DEFAULT NULL COMMENT '用户ID',
`role_id` bigint(11) DEFAULT NULL COMMENT '角色ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='用户与角色关系表';
CREATE TABLE `tb_logs` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) DEFAULT NULL COMMENT '用户名',
`operation` varchar(50) DEFAULT NULL COMMENT '用户操作',
`method` varchar(200) DEFAULT NULL COMMENT '请求方法',
`params` varchar(5000) DEFAULT NULL COMMENT '请求参数',
`time` bigint(20) NOT NULL COMMENT '执行时长(毫秒)',
`ip` varchar(64) DEFAULT NULL COMMENT 'IP地址',
`createdTime` datetime DEFAULT NULL COMMENT '创建时间',
`status` int(11) DEFAULT '1',
`error` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='操作日志';
create table `tb_files`(
`id` bigint not null auto_increment comment 'id',
`name` varchar(50) DEFAULT NULL COMMENT '文件名',
`url` varchar(255) comment '文件地址',
primary key (id)
)ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='文件';
INSERT INTO `tb_menus` VALUES (1, 'select resource', 'sys:res:list');
INSERT INTO `tb_menus` VALUES (2, 'upload resource', 'sys:res:create');
INSERT INTO `tb_menus` VALUES (3, 'delete roles', 'sys:res:delete');
INSERT INTO `tb_roles` VALUES (1, 'ADMIN');
INSERT INTO `tb_roles` VALUES (2, 'USER');
INSERT INTO `tb_role_menus` VALUES (1, 1, 1);
INSERT INTO `tb_role_menus` VALUES (2, 1, 2);
INSERT INTO `tb_role_menus` VALUES (3, 1, 3);
INSERT INTO `tb_role_menus` VALUES (4, 2, 1);
INSERT INTO `tb_users` VALUES (1, 'admin', '$2a$10$5T851lZ7bc2U87zjt/9S6OkwmLW62tLeGLB2aCmq3XRZHA7OI7Dqa', 'NORMAL');
INSERT INTO `tb_users` VALUES (2, 'user', '$2a$10$szHoqQ64g66PymVJkip98.Fap21Csy8w.RD8v5Dhq08BMEZ9KaSmS', 'NORMAL');
INSERT INTO `tb_user_roles` VALUES (1, 1, 1);
INSERT INTO `tb_user_roles` VALUES (2, 2, 2);
数据库表结构图
表关联结构图
创建sca-system工程
工程结构
关键代码
dao层:
package com.jt.system.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jt.system.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* @Author 作者:小龙猿
* @Project 项目:CGB2105-Step04
* @Time 时间:2021/8/31 15:34
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
@Select("select id,username,password,status "+
"from tb_users "+
"where username=#{username}")
User selectUserByUsername(String username);//根据登录时的用户名查询用户信息
@Select("SELECT m.permission " +
"from tb_user_roles ur join tb_role_menus rm " +
"on ur.role_id=rm.role_id " +
"join tb_menus m on rm.menu_id=m.id " +
"where ur.user_id = #{userId}")
List<String> selectUserPermissions(Long userId);//根据登录后的用户id查询该用户的权限(涉及多表关联查询)
}
bootstrap.yml文件:
server:
port: 8061
spring:
application:
name: sca-system
cloud:
nacos:
discovery:
server-addr: localhost:8848
config:
server-addr: localhost:8848
file-extension: yml
datasource:
url: jdbc:mysql:///jt-sso?serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: root
修改sca-auth工程
现工程结构
关键代码
创建RemoteUserService接口,实现远程服务调用,在这里调用的是sca-system工程里的服务.
package com.jt.auth.feign;
import com.jt.auth.pojo.User;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
/**
* @Author 作者:小龙猿
* @Project 项目:CGB2105-Step04
* @Time 时间:2021/8/31 17:26
*/
@FeignClient(name = "sca-system",contextId = "remoteUserService")
//还需要在启动类上加一个注解@EnableFeignClients
public interface RemoteUserService {
@GetMapping("/user/login/{username}")
User selectUserByUsername(@PathVariable("username") String username);
@GetMapping("/user/permission/{userId}")
List<String> selectPerById(@PathVariable("userId") Long userId);
}
注解解释:
- @FeignClient 注解 : 用于描述远程调用接口,这个接口不需要你写实现类,你只需要定义访问规则即可(例如请求方式,请求URL,请求参数).@FeignClient 注解描述的接口的实现类对象会默认交给spring管理,这个bean对象的名字默认就是name属性指定的值,这个name还有一个层面的含义,就是你远程调用的服务名.假如@FeignClient注解中添加了contextId属性,则这个属性值默认会作为当前bean对象的名字,此时name的值仅仅作为要调用的服务名对待,一般推荐contextId的值默认为@FeignClient注解描述的接口的名字(首字母小写).
- @EnableFeignClients 注解 : 描述启动类时,用于告诉springboot在启动时, 扫描启动类所在包或子包中的类,假如接口上有@FeignClient注解描述, 则对这样的接口创建其实现类,在实现类内部帮我们进行远程服务调用.
修改UserDetailsServiceImpl类
package com.jt.auth.service;
import com.jt.auth.feign.RemoteUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 登录时用户信息的获取和封装会在此对象进行实现,
* 在页面点击登录按钮时会调用这个对象的loadUserByUsername方法,
* 页面上输入的用户名会传给这个方法的参数
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private RemoteUserService remoteUserService;
//UserDetails用户封装用户信息(认证和权限信息)
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
//1.基于用户名查询用户信息(用户名,用户密码,用户状态...)
com.jt.auth.pojo.User user = remoteUserService.selectUserByUsername(username);
//String encodePassword = passwordEncoder.encode("123456");
//2.查询用户权限信息
List<String> permissions = remoteUserService.selectPerById(user.getId());
System.out.println("permission:"+permissions);
List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList(//实现动态获取用户权限
permissions.toArray(new String[]{}));//用户的访问权限设置的是这些才可以进行访问,对应ResourceController类里面的@PreAuthorize注解
//3.对用户信息进行封装
return new User(username, user.getPassword(), authorities);
}
}
自行对比与原来的区别,主要实现了相关数据的动态获取.
注意sca-system工程与sca-auth工程里面都有User用户信息类,且它们的内容也是一样的,所以后续会将这些共有的结构另外放到一个工程里面,其它工程需要使用时自行调用.