学成在线(第16天)Spring Security Oauth2
用户认证需求分析
用户认证需求分析
用户认证与授权
什么是用户身份认证?
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认
证表现形式有:用户名密码登录,指纹打卡等方式。
什么是用户授权?
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没
有权限的资源将无法访问,这个过程叫用户授权。
单点登录需求
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。
SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
下图是SSO的示意图,用户登录学成网一次即可访问多个系统。
第三方认证
什么是第三方认证(跨平台认证)?
当需要访问第三方系统的资源时需要首先通过第三方系统的认证(例如:微信认证),由第三方系统对用户认证通
过,并授权资源的访问权限。
用户认证技术方案
单点登录技术方案
分布式系统要实现单点登录,通常将认证系统独立抽取出来,并且将用户身份信息存储在单独的存储介质,比如:
MySQL、Redis,考虑性能要求,通常存储在Redis中,如下图:
单点登录的特点是:
1、认证系统为独立的系统。
2、各子系统通过Http或其它协议与认证系统通信,完成用户认证。
3、用户身份信息存储在Redis集群。
Java中有很多用户认证的框架都可以实现单点登录:
1、Apache Shiro.
2、CAS
3、Spring security CAS
Oauth2 认证
第三方认证技术方案最主要是解决认证协议的通用标准 问题,因为要实现 跨系统认证,各系统之间要遵循一定的
接口协议。
OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认
证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实
现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网
很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明
OAUTH标准逐渐成为开放资源授权的标准。
下边分析一个Oauth2认证的例子,黑马程序员网站使用微信认证的过程:
Oauth2包括以下角色:
1、客户端
本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:学成在线Android客户端、学成在
线Web客户端(浏览器端)、微信客户端等。
2、资源拥有者
通常为用户,也可以是应用程序,即该资源的拥有者。
3、授权服务器(也称认证服务器)
用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授
权后方可访问。
4、资源服务器
存储资源的服务器,比如,学成网用户管理服务器存储了学成网的用户信息,学成网学习服务器存储了学生的学习
信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。
Spring security Oauth2 认证解决方案
本项目采用 Spring security + Oauth2完成用户认证及用户授权,Spring security 是一个强大的和高度可定制的身
份验证和访问控制框架,Spring security 框架集成了Oauth2协议,下图是项目认证架构图:
1、用户请求认证服务完成认证。
2、认证服务下发用户身份令牌,拥有身份令牌表示身份合法。
3、用户携带令牌请求资源服务,请求资源服务必先经过网关。
4、网关校验用户身份令牌的合法,不合法表示用户没有登录,如果合法则放行继续访问。
5、资源服务获取令牌,根据令牌完成授权。
6、资源服务完成授权则响应资源信息。
Spring Security Oauth2 研究
Oauth2 授权码模式
Oauth2有以下授权模式:
授权码模式(Authorization Code) 隐式授权模式(Implicit) 密码模式(Resource Owner Password
Credentials) 客户端模式(Client Credentials)
其中授权码模式和密码模式应用较多,本小节介绍授权码模式。
申请授权码
请求认证服务获取授权码:
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。
接下来进入授权页面:
点击“同意”。
接下来返回授权码:
认证服务携带授权码跳转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中请求服务端
点击发送:
申请令牌成功:
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、令牌有效,资源服务向客户端响应资源信息
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令牌生成采用非对称加密算法
1、生成密钥证书
下边命令生成密钥证书,采用RSA 算法每个证书包含公钥和私钥
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
生成jwt令牌
在认证工程创建测试类,测试jwt令牌的生成与验证。
/生成一个jwt令牌 @Test public void testCreateJwt(){ //证书文件 String key_location = "xc.keystore"; //密钥库密码 String keystore_password = "xuechengkeystore"; //访问证书路径 ClassPathResource resource = new ClassPathResource(key_location); //密钥工厂 KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(resource, keystore_password.toCharArray()); //密钥的密码,此密码和别名要匹配 String keypassword = "xuecheng"; //密钥别名 String alias = "xckey"; //密钥对(密钥和公钥) KeyPair keyPair = keyStoreKeyFactory.getKeyPair(alias,keypassword.toCharArray()); //私钥 RSAPrivateKey aPrivate = (RSAPrivateKey) keyPair.getPrivate(); //定义payload信息 Map<String, Object> tokenMap = new HashMap<>(); tokenMap.put("id", "123"); tokenMap.put("name", "mrt"); tokenMap.put("roles", "r01,r02"); tokenMap.put("ext", "1"); //生成jwt令牌 Jwt jwt = JwtHelper.encode(JSON.toJSONString(tokenMap), new RsaSigner(aPrivate)); //取出jwt令牌 String token = jwt.getEncoded(); System.out.println("token="+token); }
验证jwt令牌
//资源服务使用公钥验证jwt的合法性,并对jwt解码 @Test public void testVerify(){ //jwt令牌 String token ="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHQiOiIxIiwicm9sZXMiOiJyMDEscjAyIiwibmFtZSI6Im1ydCIsI mlkIjoiMTIzIn0.KK7_67N5d1Dthd1PgDHMsbi0UlmjGRcm_XJUUwseJ2eZyJJWoPP2IcEZgAU3tUaaKEHUf9wSRwaDgwhrw fyIcSHbs8oy3zOQEL8j5AOjzBBs7vnRmB7DbSaQD7eJiQVJOXO1QpdmEFgjhc_IBCVTJCVWgZw60IEW1_Lg5tqaLvCiIl26K 48pJB5f‐le2zgYMzqR1L2LyTFkq39rG57VOqqSCi3dapsZQd4ctq95SJCXgGdrUDWtD52rp5o6_0uq‐ mrbRdRxkrQfsa1j8C5IW2‐T4eUmiN3f9wF9JxUK1__XC1OQkOn‐ZTBCdqwWIygDFbU7sf6KzfHJTm5vfjp6NIA"; //公钥 String publickey = "‐‐‐‐‐BEGIN PUBLIC KEY‐‐‐‐‐ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAijyxMdq4S6L1Af1rtB8SjCZHNgsQG8JTfGy55eYvzG0B/E4AudR2 prSRBvF7NYPL47scRCNPgLnvbQczBHbBug6uOr78qnWsYxHlW6Aa5dI5NsmOD4DLtSw8eX0hFyK5Fj6ScYOSFBz9cd1nNTvx 2+oIv0lJDcpQdQhsfgsEr1ntvWterZt/8r7xNN83gHYuZ6TM5MYvjQNBc5qC7Krs9wM7UoQuL+s0X6RlOib7/mcLn/lFLsLD dYQAZkSDx/6+t+1oHdMarChIPYT1sx9Dwj2j2mvFNDTKKKKAq0cv14Vrhz67Vjmz2yMJePDqUi0JYS2r0iIo7n8vN7s83v5u OQIDAQAB‐‐‐‐‐END PUBLIC KEY‐‐‐‐‐"; //校验jwt Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(publickey)); //获取jwt原始内容 String claims = jwt.getClaims(); //jwt令牌 String encoded = jwt.getEncoded(); System.out.println(encoded); }
认证接口开发
需求分析
用户登录的流程图如下:
执行流程:
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 连接配置
在认证服务的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没有限制
测试
@SpringBootTest @RunWith(SpringRunner.class) public class RedisTest { @Autowired private StringRedisTemplate stringRedisTemplate; @Test public void testRedis(){ //定义key String key = "user_token:9734b68f‐cf5e‐456f‐9bd6‐df578c711390"; //定义Map Map<String,String> mapValue = new HashMap<>(); mapValue.put("id","101"); mapValue.put("username","itcast"); String value = JSON.toJSONString(mapValue); //向redis中存储字符串 stringRedisTemplate.boundValueOps(key).set(value,60, TimeUnit.SECONDS); //读取过期时间,已过期返回‐2 Long expire = stringRedisTemplate.getExpire(key); //根据key获取value String s = stringRedisTemplate.opsForValue().get(key); System.out.println(s); } }
认证服务
认证服务需要实现的功能如下:
1、登录接口
前端post提交账号、密码等,用户身份校验通过,生成令牌,并将令牌存储到redis。
将令牌写入cookie。
2、退出接口
校验当前用户的身份为合法并且为已登录状态。
将令牌从redis删除。
删除cookie中的令牌。
业务流程如下:
Api接口
@Api(value = "用户认证",description = "用户认证接口") public interface AuthControllerApi { @ApiOperation("登录") public LoginResult login(LoginRequest loginRequest); @ApiOperation("退出") public ResponseResult logout(); }
配置参数
在application.yml中配置参数
auth: tokenValiditySeconds: 1200 #token存储到redis的过期时间 clientId: XcWebApp clientSecret: XcWebApp cookieDomain: localhost cookieMaxAge: ‐1
申请令牌测试
@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); } 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令牌的唯一标识作为用户身份令牌 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); } @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"); }
测试认证接口
使用postman测试:
Post请求:http://localhost:40400/auth/userlogin