赞
踩
1用户认证需求分析
1.1 用户认证与授权
什么是用户身份认证?
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证表现形式有:用户名密码登录,指纹打卡等方式。
什么是用户授权?
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。
1.2 单点登录需求
一个项目可以包括多个子项目,如:学习系统,教学管理中心、系统管理中心等,为了提高用户体验性需要实现用户只认证一次便可以在多个拥有访问权限的系统中访问,这个功能叫做单点登录。
1.3 第三方认证需求
一个微信用户没有在本系统注册,本系统可以通过请求微信系统来验证该用户的身份,验证通过后该用户便可在本系统学习
微信不属于本系统,本系统并没有存储微信用户的账号、密码等信息,本系统如果要获取该用户的基本信息则需要首先通过微信的认证系统(微信认证)进行认证,微信认证通过后本系统便可获取该微信用户的基本信息,从而在本系统将该微信用户的头像、昵称等信息显示出来,该用户便不用在本系统注册却可以直接学习。
什么是第三方认证(跨平台认证)?
当需要访问第三方系统的资源时需要首先通过第三方系统的认证(例如:微信认证),由第三方系统对用户认证通过,并授权资源的访问权限。
2用户认证技术方案
2.1单点登录技术方案
分布式系统要实现单点登录,通常将认证系统独立抽取出来,并且将用户身份信息存储在单独的存储介质,比如:
MySQL、Redis,考虑性能要求,通常存储在Redis中
单点登录的特点是:
1、认证系统为独立的系统。
2、各子系统通过Http或其它协议与认证系统通信,完成用户认证。
3、用户身份信息存储在Redis集群。
Java中有很多用户认证的框架都可以实现单点登录:
1、Apache Shiro.
2、CAS
3、Spring security CAS
2.2 Oauth2认证
2.2.1 Oauth2认证流程
第三方认证技术方案最主要是解决认证协议的通用标准 问题,因为要实现 跨系统认证,各系统之间要遵循一定的接口协议。
OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。
Oauth协议目前发展到2.0版本,1.0版本过于复杂,2.0版本已得到广泛应用。
2.3 Spring security Oauth2认证解决方案
本项目采用 Spring security + Oauth2完成用户认证及用户授权,Spring security 是一个强大的和高度可定制的身份验证和访问控制框架,Spring security 框架集成了Oauth2协议,下图是项目认证架构图:
1、用户请求认证服务完成认证。
2、认证服务下发用户身份令牌,拥有身份令牌表示身份合法。
3、用户携带令牌请求资源服务,请求资源服务必先经过网关。
4、网关校验用户身份令牌的合法,不合法表示用户没有登录,如果合法则放行继续访问。
5、资源服务获取令牌,根据令牌完成授权。
6、资源服务完成授权则响应资源信息。
Spring Security Oauth2研究
创建数据库
以“oauth_”开头的表都是spring Security 自带的表。
本项目中spring Security 主要使用oauth_client_details表:
client_id:客户端id
resource_ids:资源id(暂时不用)
client_secret:客户端密码
scope:范围
access_token_validity:访问token的有效期(秒)
refresh_token_validity:刷新token的有效期(秒)
authorized_grant_type:授权类型,authorization_code,password,refresh_token,client_credentials
Oauth2授权模式
Oauth2有以下授权模式:
授权码模式(Authorization Code) 隐式授权模式(Implicit) 密码模式(Resource Owner Password Credentials) 客户端模式(Client Credentials)
其中授权码模式和密码模式应用较多,本小节介绍授权码模式。
授权码授权流程
网站使用微信认证的过程就是授权码模式,流程如下:
1、客户端请求第三方授权
2、用户(资源拥有者)同意给客户端授权
3、客户端获取到授权码,请求认证服务器申请令牌
4、认证服务器向客户端响应令牌
5、客户端请求资源服务器的资源,资源服务校验令牌合法性,完成授权
6、资源服务器返回受保护资源
申请授权码
请求认证服务获取授权码:
Get请求:
localhost:40400/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scop=app&redirect_uri=http://localhost
参数列表如下:
client_id:客户端id,和授权配置类中设置的客户端id一致。
response_type:授权码模式固定为code
scop:客户端范围,和授权配置类中设置的scop一致。
redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。
首先跳转到登录页面:
输入账号和密码,点击Login。
Spring Security接收到请求会调用UserDetailsService接口的loadUserByUsername方法查询用户正确的密码。
当前导入的基础工程中将正确的密码硬编码为“123”,所以这里账号随意输入,密码输入123即可认证通过。
接下来进入授权页面:
点击“同意”。
接下来返回授权码:
认证服务携带授权码跳转redirect_uri
申请令牌
拿到授权码后,申请令牌。
Post请求:http://localhost:40400/auth/oauth/token
参数如下:
grant_type:授权类型,填写authorization_code,表示授权码模式
code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
此链接需要使用 http Basic认证。
什么是http Basic认证?
http协议定义的一种认证方式,将客户端id和客户端密码按照“客户端ID:客户端密码”的格式拼接,并用base64编
码,放在header中请求服务端,一个例子:
Authorization:Basic WGNXZWJBcHA6WGNXZWJBcHA=WGNXZWJBcHA6WGNXZWJBcHA= 是用户名:密码的base64编码。
认证失败服务端返回 401 Unauthorized
以上测试使用postman完成:
http basic认证:
客户端Id和客户端密码会匹配数据库oauth_client_details表中的客户端id及客户端密码。
Post请求参数:
点击发送:
申请令牌成功
access_token:访问令牌,携带此令牌访问资源
token_type:有MAC Token与Bearer Token两种类型,两种的校验算法不同,RFC 6750建议Oauth2采用 Bearer
Token(http://www.rfcreader.com/#rfc6750)。
refresh_token:刷新令牌,使用此令牌可以延长访问令牌的过期时间。
expires_in:过期时间,单位为秒。
scope:范围,与定义的客户端范围一致。
资源服务授权
资源服务授权流程
资源服务拥有要访问的受保护资源,客户端携带令牌访问资源服务,如果令牌合法则可成功访问资源服务中的资源,如下图:
上图的业务流程如下:
1、客户端请求认证服务申请令牌
2、认证服务生成令牌
认证服务采用非对称加密算法,使用私钥生成令牌。
3、客户端携带令牌访问资源服务
客户端在Http header 中添加: Authorization:Bearer 令牌。
4、资源服务请求认证服务校验令牌的有效性
资源服务接收到令牌,使用公钥校验令牌的合法性。
5、令牌有效,资源服务向客户端响应资源信息
资源服务授权配置
基本上所有微服务都是资源服务,这里我们在课程管理服务上配置授权控制,当配置了授权控制后如要访问课程信息则必须提供令牌。
1、配置公钥
认证服务生成令牌采用非对称加密算法,认证服务采用私钥加密生成令牌,对外向资源服务提供公钥,资源服务使用公钥 来校验令牌的合法性。将公钥拷贝到 publickey.txt文件中,将此文件拷贝到资源服务工程的classpath下
2、添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring‐cloud‐starter‐oauth2</artifactId>
</dependency>
4、在config包下创建ResourceServerConfig类:
@Configuration @EnableResourceServer @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解 public class ResourceServerConfig extends ResourceServerConfigurerAdapter { //公钥 private static final String PUBLIC_KEY = "publickey.txt"; //定义JwtTokenStore,使用jwt令牌 @Bean public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) { return new JwtTokenStore(jwtAccessTokenConverter); } //定义JJwtAccessTokenConverter,使用jwt令牌 @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(getPubKey()); return converter; } /** * 获取非对称加密公钥 Key * @return 公钥 Key */ private String getPubKey() { Resource resource = new ClassPathResource(PUBLIC_KEY); try { InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream()); BufferedReader br = new BufferedReader(inputStreamReader); return br.lines().collect(Collectors.joining("\n")); } catch (IOException ioe) { return null; } } //Http安全配置,对每个到达系统的http请求链接进行校验 @Override public void configure(HttpSecurity http) throws Exception { //所有请求必须认证通过 http.authorizeRequests().anyRequest().authenticated(); } }
资源服务授权测试
这里我们测试课程图片查询
get http://localhost:31200/course/coursepic/list/4028e58161bd3b380161bd3bcd2f0000
请求时没有携带令牌则报错:
{
“error”: “unauthorized”,
“error_description”: “Full authentication is required to access this resource”
}
请求时携带令牌:
在http header中添加 Authorization: Bearer 令牌
当输入错误的令牌也无法正常访问资源。
解决swagger-ui无法访问
当课程管理加了授权之后再访问swagger-ui则报错:
修改授权配置类ResourceServerConfig的configure方法:
针对swagger-ui的请求路径进行放行:
//Http安全配置,对每个到达系统的http请求链接进行校验
@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","/webjars/**").permitAll() //配置了"/webjars/**"之后,则对所有的资源都放行,开发阶段单元测试时使用
.anyRequest().authenticated();
}
注意:
通过上边的配置虽然可以访问swagger-ui,但是无法进行单元测试,除非去掉认证的配置或在上边配置中添加所有请求均放行("/**")。
Oauth2密码模式授权
密码模式(Resource Owner Password Credentials)与授权码模式的区别是申请令牌不再使用授权码,而是直接通过用户名和密码即可申请令牌。
测试如下:
Post请求:http://localhost:40400/auth/oauth/token
参数:
grant_type:密码模式授权填写password
username:账号
password:密码
并且此链接需要使用 http Basic认证。
注意:当令牌没有过期时同一个用户再次申请令牌则不再颁发新令牌。
校验令牌
Spring Security Oauth2提供校验令牌的端点,如下:Get: http://localhost:40400/auth/oauth/check_token?token=
参数:
token:令牌
使用postman测试如下:
exp:过期时间,long类型,距离1970年的秒数(new Date().getTime()可得到当前时间距离1970年的毫秒数)。
user_name: 用户名
client_id:客户端Id,在oauth_client_details中配置
scope:客户端范围,在oauth_client_details表中配置
jti:与令牌对应的唯一标识
companyId、userpic、name、utype、id:这些字段是本认证服务在Spring Security基础上扩展的用户身份信息
刷新令牌
刷新令牌是当令牌快过期时重新生成一个令牌,它于授权码授权和密码授权生成令牌不同,刷新令牌不需要授权码也不需要账号和密码,只需要一个刷新令牌、客户端id和客户端密码。
测试如下:
Post:http://localhost:40400/auth/oauth/token
参数:
grant_type: 固定为 refresh_token
refresh_token:刷新令牌(注意不是access_token,而是refresh_token)
刷新令牌成功,会重新生成新的访问令牌和刷新令牌,令牌的有效期也比旧令牌长。
刷新令牌通常是在令牌快过期时进行刷新。
JWT介绍
在介绍JWT之前先看一下传统校验令牌的方法,如下图:
问题:
传统授权方法的问题是用户每次请求资源服务,资源服务都需要携带令牌访问认证服务去校验令牌的合法性,并根据令牌获取用户的相关信息,性能低下。
解决:
使用JWT的思路是,用户认证通过会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。
JWT令牌授权过程如下图:
什么是JWT?
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。
JWT令牌的优点:
1、jwt基于json,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:
1、JWT令牌较长,占存储空间比较大。
令牌结构
通过学习JWT令牌结构为自定义jwt令牌打好基础。
JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
Header
头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
一个例子如下:
下边是Header部分的内容
{
“alg”: “HS256”,
“typ”: “JWT”
}
将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。
Payload
第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。
一个例子:
{
“sub”: “1234567890”,
“name”: “456”,
“admin”: true
}
Signature
第三部分是签名,此部分用于防止jwt内容被篡改。
这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明签名算法进行签名。
一个例子:
HMACSHA256(
base64UrlEncode(header) + “.” +
base64UrlEncode(payload),
secret)
base64UrlEncode(header):jwt令牌的第一部分。
base64UrlEncode(payload):jwt令牌的第二部分。
secret:签名所使用的密钥。
资源服务校验令牌之前,令牌的第三部分是通过密钥和令牌的第一第二部分组合而成的,资源服务校验令牌的时候,如果令牌的前两部分被修改了,资源服务中配置的公钥和令牌的前两部分组合生成签名,然后和之前的第三部分签名进行比较会发现两者是不同的,则校验失败
即新的签名和旧的签名不同
JWT入门
生成私钥和公钥
JWT令牌生成采用非对称加密算法
1、生成密钥证书
下边命令生成密钥证书,采用RSA 算法每个证书包含公钥和私钥
打开cmd:在任意目录下输入如下命令即可生成密钥证书
keytool -genkeypair -alias xckey -keyalg RSA -keypass xuecheng -keystore xc.keystore -storepass xuechengkeystore
Keytool 是一个java提供的证书管理工具
-alias:密钥的别名
-keyalg:使用的hash算法
-keypass:密钥的访问密码
-keystore:密钥库文件名,xc.keystore保存了生成的证书
-storepass:密钥库的访问密码
查询证书信息:
keytool -list -keystore xc.keystore
删除别名
keytool -delete -alias xckey -keystore xc.keystore
2、导出公钥
openssl是一个加解密工具包,这里使用openssl来导出公钥信息。
安装 openssl:http://slproweb.com/products/Win32OpenSSL.html
安装资料目录下的Win64OpenSSL-1_1_0g.exe
配置openssl的path环境变量,本教程配置在D:\OpenSSL-Win64\bin
cmd进入xc.keystore文件所在目录执行如下命令:
keytool ‐list ‐rfc ‐‐keystore xc.keystore | openssl x509 ‐inform pem ‐pubkey
输入密钥库密码:
将上边的公钥拷贝到文本文件中,合并为一行。(防止换行符会有影响)
生成jwt令牌
在认证工程创建测试类,测试jwt令牌的生成与验证。
认证接口开发
用户登录的流程图如下:
执行流程:
1、用户登录,请求认证服务
2、认证服务认证通过,生成jwt令牌,将jwt令牌及相关信息写入Redis,并且将身份令牌写入cookie
3、用户访问资源页面,带着cookie到网关
4、网关从cookie获取token,并查询Redis校验token,如果token不存在则拒绝访问,否则放行
5、用户退出,请求认证服务,清除redis中的token,并且删除cookie中的token
使用redis存储用户的身份令牌有以下作用:
1、实现用户退出注销功能,服务端清除令牌后,即使客户端请求携带token也是无效的。
2、由于jwt令牌过长,不宜存储在cookie中,所以将jwt令牌存储在redis,由客户端请求服务端获取并在客户端存储
redis连接配置(因为需要将令牌存到redis中,所以需要配置redis连接)
在认证服务的application.yml文件中添加如下配置:
spring:
application:
name: xc‐service‐ucenter‐auth
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没有限制
认证服务需要实现的功能如下:
1、登录接口
前端post提交账号、密码等,用户身份校验通过,生成令牌,并将令牌存储到redis。
将令牌写入cookie。
2、退出接口
校验当前用户的身份为合法并且为已登录状态。
将令牌从redis删除。
删除cookie中的令牌。
业务流程如下:
配置参数
在application.yml中配置参数
auth:
tokenValiditySeconds: 1200 #token存储到redis的过期时间
clientId: XcWebApp
clientSecret: XcWebApp
cookieDomain: localhost
cookieMaxAge: ‐1
申请令牌测试
为了不破坏Spring Security的代码,我们在Service方法中通过RestTemplate请求Spring Security所暴露的申请令牌接口来申请令牌,下边是测试代码:
@SpringBootTest @RunWith(SpringRunner.class) public class TestClient { @Autowired LoadBalancerClient loadBalancerClient; @Autowired RestTemplate restTemplate; @Test public void testClient(){ //采用客户端负载均衡,从eureka获取认证服务的ip 和端口 ServiceInstance serviceInstance = loadBalancerClient.choose(XcServiceList.XC_SERVICE_UCENTER_AUTH); URI uri = serviceInstance.getUri(); String authUrl = uri+"/auth/oauth/token"; //URI url, HttpMethod method, HttpEntity<?> requestEntity, Class<T> responseType // url就是 申请令牌的url /oauth/token //method http的方法类型 //requestEntity请求内容 //responseType,将响应的结果生成的类型 //请求的内容分两部分 //1、header信息,包括了http basic认证信息 MultiValueMap<String, String> headers = new LinkedMultiValueMap<String, String>(); //调用私有方法 String httpbasic = httpbasic("XcWebApp", "XcWebApp"); //"Basic WGNXZWJBcHA6WGNXZWJBcHA=" headers.add("Authorization", httpbasic); //2、包括:grant_type、username、passowrd MultiValueMap<String, String> body = new LinkedMultiValueMap<String, String>(); body.add("grant_type","password"); body.add("username","itcast"); body.add("password","123"); HttpEntity<MultiValueMap<String, String>> multiValueMapHttpEntity = new HttpEntity<MultiValueMap<String, String>>(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); } } }); //远程调用申请令牌 ResponseEntity<Map> exchange = restTemplate.exchange(authUrl, HttpMethod.POST,multiValueMapHttpEntity, Map.class); Map body1 = exchange.getBody(); System.out.println(body1); } //定义私有方法,将客户端id和客户端密码进行Base64位编码 private String httpbasic(String clientId,String clientSecret){ //将客户端id和客户端密码拼接,按“客户端id:客户端密码” String string = clientId+":"+clientSecret; //进行base64编码 byte[] encode = Base64.encode(string.getBytes()); return "Basic "+new String(encode); } }
Service
调用认证服务申请令牌,并将令牌存储到 redis。
1、AuthToken
创建 AuthToken模型类,存储申请的令牌,包括身份令牌、刷新令牌、jwt令牌
身份令牌:用于校验用户是否认证
刷新令牌:jwt令牌快过期时执行刷新令牌
jwt令牌:用于授权
@Data
@ToString
@NoArgsConstructor
public class AuthToken {
String access_token;//身份token
String refresh_token;//刷新token
String jwt_token;//jwt令牌
}
申请令牌的service方法如下:
@Service public class AuthService { private static final Logger LOGGER = LoggerFactory.getLogger(AuthService.class); @Value("${auth.tokenValiditySeconds}") int tokenValiditySeconds; @Autowired RestTemplate restTemplate; @Autowired LoadBalancerClient loadBalancerClient; @Autowired StringRedisTemplate stringRedisTemplate; //认证方法 public AuthToken login(String username,String password,String clientId,String clientSecret){ //申请令牌 AuthToken authToken = applyToken(username,password,clientId, clientSecret); if(authToken == null){ ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL); } //将 token存储到redis String access_token = authToken.getAccess_token(); String content = JSON.toJSONString(authToken); boolean saveTokenResult = saveToken(access_token, content, tokenValiditySeconds); if(!saveTokenResult){ ExceptionCast.cast(AuthCode.AUTH_LOGIN_TOKEN_SAVEFAIL); } return authToken; } //存储令牌到redis private boolean saveToken(String access_token,String content,long ttl){ //令牌名称 String name = "user_token:" + access_token; //保存到令牌到redis stringRedisTemplate.boundValueOps(name).set(content,ttl, TimeUnit.SECONDS); //获取过期时间 Long expire = stringRedisTemplate.getExpire(name); return expire>0; } //认证方法 private AuthToken applyToken(String username,String password,String clientId,String clientSecret){ //选中认证服务的地址 ServiceInstance serviceInstance = loadBalancerClient.choose(XcServiceList.XC_SERVICE_UCENTER_AUTH); if (serviceInstance == null) { LOGGER.error("choose an auth instance fail"); ExceptionCast.cast(AuthCode.AUTH_LOGIN_AUTHSERVER_NOTFOUND); } //获取令牌的url String path = serviceInstance.getUri().toString()+"/auth/oauth/token"; //定义body MultiValueMap<String,String> formData = new LinkedMultiValueMap<>(); //授权方式 formData.add("grant_type", "password"); //账号 formData.add("username",username); //密码 formData.add("password", password); //定义头 MultiValueMap<String,String> header = new LinkedMultiValueMap<>(); header.add("Authorization", httpbasic(clientId,clientSecret)); //指定 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 { //http请求spring security的申请令牌接口 ResponseEntity<Map> mapResponseEntity = restTemplate.exchange(path, HttpMethod.POST, new HttpEntity<MultiValueMap<String, String>>(formData, header), Map.class); map = mapResponseEntity.getBody(); } catch (RestClientException 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 = new AuthToken(); //访问令牌(jwt) String jwt_token = (String) map.get("access_token"); //刷新令牌(jwt) String refresh_token = (String) map.get("refresh_token"); //jti,作为用户的身份标识 String access_token = (String) map.get("jti"); authToken.setJwt_token(jwt_token); authToken.setAccess_token(access_token); authToken.setRefresh_token(refresh_token); return authToken; } //获取httpbasic认证串 private String httpbasic(String clientId,String clientSecret){ //将客户端id和客户端密码拼接,按“客户端id:客户端密码” String string = clientId+":"+clientSecret; //进行base64编码 byte[] encode = Base64.encode(string.getBytes()); return "Basic "+new String(encode); } }
Controller
AuthController代码如下:
@RestController public class AuthController implements AuthControllerApi { @Value("${auth.clientId}") String clientId; @Value("${auth.clientSecret}") String clientSecret; @Value("${auth.cookieDomain}") String cookieDomain; @Value("${auth.cookieMaxAge}") int cookieMaxAge; @Value("${auth.tokenValiditySeconds}") int tokenValiditySeconds; @Autowired AuthService authService; @Override @PostMapping("/userlogin") public LoginResult login(LoginRequest loginRequest) { //校验账号是否输入 if(loginRequest == null || StringUtils.isEmpty(loginRequest.getUsername())){ ExceptionCast.cast(AuthCode.AUTH_USERNAME_NONE); } //校验密码是否输入 if(StringUtils.isEmpty(loginRequest.getPassword())){ ExceptionCast.cast(AuthCode.AUTH_PASSWORD_NONE); } AuthToken authToken = authService.login(loginRequest.getUsername(), loginRequest.getPassword(), clientId, clientSecret); //将令牌写入cookie //访问token String access_token = authToken.getAccess_token(); //将访问令牌存储到cookie saveCookie(access_token); return new LoginResult(CommonCode.SUCCESS,access_token); } //将令牌保存到cookie private void saveCookie(String token){ HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse(); //添加cookie 认证令牌,最后一个参数设置为false,表示允许浏览器获取 CookieUtil.addCookie(response, cookieDomain, "/", "uid", token, cookieMaxAge, false);//如果设置为true,则浏览器拿不到cookie } @Override @PostMapping("/userlogout") public ResponseResult logout() { return null; } }
登录url放行
认证服务默认都要校验用户的身份信息,这里需要将登录url放行
在WebSecurityConfig类中重写 configure(WebSecurity web)方法,如下:
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/userlogin");
}
用户认证流程分析
业务流程说明如下:
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令牌,完成授权,拥有权限的方法正常执行,没有权限的方法将拒绝访问。
认证服务查询数据库
认证服务根据数据库中的用户信息去校验用户的身份,即校验账号和密码是否匹配。
认证服务不直接连接数据库,而是通过用户中心服务去查询用户中心数据库。
调用查询用户接口
创建client
认证服务需要远程调用用户中心服务查询用户,在认证服务中创建Feign客户端
@FeignClient(value = XcServiceList.XC_SERVICE_UCENTER)
public interface UserClient {
@GetMapping("/ucenter/getuserext")
public XcUserExt getUserext(@RequestParam("username") String username)
}
认证服务调用spring security接口申请令牌,spring security接口会调用UserDetailsServiceImpl从数据库查询用户,如果查询不到则返回 NULL,表示不存在;在UserDetailsServiceImpl中将正确的密码返回, spring security会自动去比对输入密码的正确性。
1、修改UserDetailsServiceImpl的loadUserByUsername方法,调用Ucenter服务的查询用户接口
@Service public class UserDetailsServiceImpl implements UserDetailsService { @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){ //返回NULL表示用户不存在,Spring Security会抛出异常 return null; } //从数据库查询用户正确的密码,Spring Security会去比对输入密码的正确性 String password = userext.getPassword(); String user_permission_string = ""; 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()); return userDetails; } }
BCryptPasswordEncoder
早期使用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
//采用bcrypt对密码进行Hash
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
Zuul介绍
什么是Zuul?
Spring Cloud Zuul是整合Netflix公司的Zuul开源项目实现的微服务网关,它实现了请求路由、负载均衡、校验过虑等 功能。
什么是网关?
服务网关是在微服务前边设置一道屏障,请求先到服务网关,网关会对请求进行过虑、校验、路由等处理。有了服务网关可以提高微服务的安全性,网关校验请求的合法性,请求不合法将被拦截,拒绝访问。
Zuul与Nginx怎么配合使用?
Zuul与Nginx在实际项目中需要配合使用,如下图,Nginx的作用是反向代理、负载均衡,Zuul的作用是保障微服务的安全访问,拦截微服务请求,校验合法性及负载均衡。
搭建网关工程
@EnableZuulProxy
注意在启动类上使用@EnableZuulProxy注解标识此工程为Zuul网关
Zuul网关具有代理的功能,根据请求的url转发到微服务,如下图:
客户端请求网关/api/learning,通过路由转发到/learning
客户端请求网关/api/course,通过路由转发到/course
路由配置
在appcation.yml中配置:
zuul:
routes:
manage‐course: #路由名称,名称任意,保持所有路由名称唯一 如果要配置路由到其他的服务,在同级配置其他的名称 如:manage-cms
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 需要从头信息中获取jwt令牌,所以不需要过滤头信息
serviceId:推荐使用服务名称,zuul会从Eureka中找到服务名称对应的ip和端口。
strip-prefix: false #true:代理转发时去掉前缀,false:代理转发时不去掉前缀,例如,为true请
求/course/coursebase/get/…,代理转发到/coursebase/get/,如果为false则代理转发到/course/coursebase/get
sensitiveHeaders:敏感头设置,默认会过虑掉cookie,这里设置为空表示不过虑
ignoredHeaders:可以设置过虑的头信息,默认为空表示不过虑任何头
过虑器
Zuul的核心就是过虑器,通过过虑器实现请求过虑,身份校验等。
ZuulFilter
自定义过虑器需要继承 ZuulFilter,ZuulFilter是一个抽象类,需要覆盖它的四个方法,如下:
1、 shouldFilter:返回一个Boolean值,判断该过滤器是否需要执行。返回true表示要执行此过虑器,否则不执行。
2、 run:过滤器的业务逻辑。
3、 filterType:返回字符串代表过滤器的类型,如下 pre:请求在被路由之前执行 routing:在路由请求时调用 post:在routing和errror过滤器之后调用 error:处理请求时发生错误调用
4、 filterOrder:此方法返回整型数值,通过此数值来定义过滤器的执行顺序,数字越小优先级越高。
测试
过虑所有请求,判断头部信息是否有Authorization,如果没有则拒绝访问,否则转发到微服务。
定义过虑器,使用@Component标识为bean。
@Component 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"); 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; } }
身份校验
实现网关连接Redis校验令牌:
1、从cookie查询用户身份令牌是否存在,不存在则拒绝访问
2、从http header查询jwt令牌是否存在,不存在则拒绝访问
3、从Redis查询user_token令牌是否过期,过期则拒绝访问
使用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; } }
说明:由于令牌存储时采用String序列化策略,所以这里用 StringRedisTemplate来查询,使用RedisTemplate无法完成查询。
3、定义LoginFilter
@Component public class LoginFilter extends ZuulFilter { private static final Logger LOGG = LoggerFactory.getLogger(LoginFilter.class); @Autowired AuthService authService; @Override public String filterType() { //四种类型:pre、routing、post、error return "pre"; } @Override public int filterOrder() { return 0; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { //上下文对象 RequestContext requestContext = RequestContext.getCurrentContext(); //请求对象 HttpServletRequest request = requestContext.getRequest(); //查询身份令牌 String access_token = authService.getTokenFromCookie(request); if(access_token == null){ //拒绝访问 access_denied(); } //从redis中校验身份令牌是否过期 long expire = authService.getExpire(access_token); if(expire<=0){ //拒绝访问 access_denied(); } //查询jwt令牌 String jwt = authService.getJwtFromHeader(request); if(jwt == null){ //拒绝访问 access_denied(); } return null; } //拒绝访问 private void access_denied(){ //上下文对象 RequestContext requestContext = RequestContext.getCurrentContext(); requestContext.setSendZuulResponse(false);//拒绝访问 //设置响应内容 ResponseResult responseResult =new ResponseResult(CommonCode.UNAUTHENTICATED); String responseResultString = JSON.toJSONString(responseResult); requestContext.setResponseBody(responseResultString); //设置状态码 requestContext.setResponseStatusCode(200); HttpServletResponse response = requestContext.getResponse(); response.setContentType("application/json;charset=utf‐8"); } }
方法授权
方法授权要完成的是资源服务根据jwt令牌完成对方法的授权,具体流程如下:
1、生成Jwt令牌时在令牌中写入用户所拥有的权限
我们给每个权限起个名字,例如某个用户拥有如下权限:
course_find_list:课程查询
course_pic_list:课程图片查询
2、在资源服务方法上添加注解PreAuthorize,并指定此方法所需要的权限
例如下边是课程管理接口方法的授权配置,它就表示要执行这个方法需要拥有course_find_list权限。
@PreAuthorize("hasAuthority('course_find_list')")
@Override
public QueryResult<CourseInfo> findCourseList(@PathVariable("page") int page,
@PathVariable("size") int size,
CourseListRequest courseListRequest)
3、当请求有权限的方法时正常访问
4、当请求没有权限的方法时则拒绝访问
在资源服务(这里是课程管理)的ResourceServerConfig类上添加注解,激活方法上添加授权注解
//激活方法上的PreAuthorize注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
动态查询用户权限
1、管理员给用户分配权限,权限数据写到数据库中。
2、认证服务在进行用户认证时从数据库读取用户的权限数据(动态数据)
权限数据模型
查询用户所拥有的权限
SELECT * FROM xc_menu WHERE id IN(
SELECT menu_id FROM xc_permission WHERE role_id IN(
SELECT role_id FROM xc_user_role WHERE user_id = '49'
)
)
用户中心查询用户权限
细粒度授权
什么是细粒度授权?
细粒度授权也叫数据范围授权,即不同的用户所拥有的操作权限相同,但是能够操作的数据范围是不一样的。一个例子:用户A和用户B都是教学机构,他们都拥有“我的课程”权限,但是两个用户所查询到的数据是不一样的。
本项目有哪些细粒度授权?
比如:
我的课程,教学机构只允许查询本教学机构下的课程信息。
我的选课,学生只允许查询自己所选课。
如何实现细粒度授权?
细粒度授权涉及到不同的业务逻辑,通常在service层实现,根据不同的用户进行校验,根据不同的参数查询不同的数据或操作不同的数据。
在xc_course数据库的course_base 表中添加company_id字段,来表示此课程的归属
如何查询某个用户的课程?
1、确定用户的Id
2、根据用户的Id查询用户归属的公司。
3、根据公司Id查询该公司下的课程信息
获取当前用户信息
要想实现只查询自己的课程信息则需要获取当前用户所属的企业id。
1、认证服务在用户认证通过将用户所属公司id等信息存储到jwt令牌中。
2、用户请求到达资源服务后,资源服务需要取出header中的jwt令牌,并解析出用户信息。
jwt令牌包括企业Id
资源服务在授权时需要用到用户所属企业ID,需要实现认证服务生成的JWT令牌中包括用户所属公司id信息。
查看认证服务UserDetailServiceImpl代码如下:
......
//用户id
userDetails.setId(userext.getId());
//用户名称
userDetails.setName(userext.getName());
//用户头像
userDetails.setUserpic(userext.getUserpic());
//用户类型
userDetails.setUtype(userext.getUtype());
//用户所属企业id
userDetails.setCompanyId(userext.getCompanyId());
return userDetails;
......
通过上边代码的分析得知,认证服务调用XcUserExt userext = userClient.getUserext(username);获取用户信息,
将userext 中的信息存储到jwt令牌中,在userext 对象中已经包括了companyId公司ID等信息。
获取当前用户
JWT解析工具类
1、在Oauth2Util工具类中,从header中取出JWT令牌,并解析JWT令牌的内容。
public class Oauth2Util { public static Map<String,String> getJwtClaimsFromHeader(HttpServletRequest request) { if (request == null) { return null; } //取出头信息 String authorization = request.getHeader("Authorization"); if (StringUtils.isEmpty(authorization) || authorization.indexOf("Bearer") < 0) { return null; } //从Bearer 后边开始取出token String token = authorization.substring(7); Map<String,String> map = null; try { //解析jwt Jwt decode = JwtHelper.decode(token); //得到 jwt中的用户信息 String claims = decode.getClaims(); //将jwt转为Map map = JSON.parseObject(claims, Map.class); } catch (Exception e) { e.printStackTrace(); } return map; } }
2、在XcOauth2Util工具类中,将解析的JWT内容封装成UserJwt对象返回。
public class XcOauth2Util { public UserJwt getUserJwtFromHeader(HttpServletRequest request){ Map<String, String> jwtClaims = Oauth2Util.getJwtClaimsFromHeader(request); if(jwtClaims == null || StringUtils.isEmpty(jwtClaims.get("id"))){ return null; } UserJwt userJwt = new UserJwt(); userJwt.setId(jwtClaims.get("id")); userJwt.setName(jwtClaims.get("name")); userJwt.setCompanyId(jwtClaims.get("companyId")); userJwt.setUtype(jwtClaims.get("utype")); userJwt.setUserpic(jwtClaims.get("userpic")); return userJwt; } @Data public class UserJwt{ private String id; private String name; private String userpic; private String utype; private String companyId; } }
获取当前用户
修改课程管理的CourseController类,将companyId的静态数据改为动态获取:
@Override
public QueryResult<CourseInfo> findCourseList(@PathVariable("page") int page,
@PathVariable("size") int size,
CourseListRequest courseListRequest) {
//调用工具类取出用户信息
XcOauth2Util xcOauth2Util = new XcOauth2Util();
XcOauth2Util.UserJwt userJwt = xcOauth2Util.getUserJwtFromHeader(request);
if(userJwt == null){
ExceptionCast.cast(CommonCode.UNAUTHENTICATED);
}
String companyId = userJwt.getCompanyId();
return courseService.findCourseList(companyId,page,size,courseListRequest);
}
微服务之间认证
前边章节已经实现了用户携带身份令牌和JWT令牌访问微服务,微服务获取jwt并完成授权。
当微服务访问微服务,此时如果没有携带JWT则微服务会在授权时报错。
Feign 拦截器
微服务之间使用feign进行远程调用,采用feign拦截器实现远程调用携带JWT。
在common工程添加依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring‐cloud‐starter‐openfeign</artifactId>
</dependency>
在Common工程定义拦截器如下:
package com.xuecheng.framework.interceptor; public class FeignClientInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate requestTemplate) { try { //使用RequestContextHolder工具获取request相关变量 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if(attributes!=null){ //取出request HttpServletRequest request = attributes.getRequest(); Enumeration<String> headerNames = request.getHeaderNames(); if (headerNames != null) { while (headerNames.hasMoreElements()) { String name = headerNames.nextElement(); String values = request.getHeader(name); if(name.equals("authorization")){ //System.out.println("name="+name+"values="+values); requestTemplate.header(name, values); } } } } }catch (Exception e) { e.printStackTrace(); } } }
使用Feign拦截器
本例子中课程管理调用cms需要携带jwt,所以需要在课程管理中定义Feign拦截器bean,在启动类中定义bean如下:
@Bean
public FeignClientInterceptor feignClientInterceptor(){
return new FeignClientInterceptor();
}
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。