JWT
普通令牌的问题
客户端申请到令牌,接下来客户端携带令牌去访问资源,
到资源服务器将会校验令牌的合法性。
资源服务器如何校验令牌的合法性?
我们以OAuth2的密码模式为例进行说明:
从第4步开始说明:
1、客户端携带令牌访问资源服务获取资源。
2、资源服务远程请求认证服务校验令牌的合法性
3、如果令牌合法资源服务向客户端返回资源。
这里存在一个问题:
就是校验令牌需要远程请求认证服务,客户端的每次访问都会远程校验,执行性能低。
如果能够让资源服务自己校验令牌的合法性将省去远程请求认证服务的成本,提高了性能。如下图:
如何解决上边的问题,实现资源服务自行校验令牌。
令牌采用JWT格式即可解决上边的问题,用户认证通过后会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。
什么是JWT
JSON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任,它可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止内容篡改。官网:https://jwt.io/
使用JWT可以实现无状态认证。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Configuration public class TokenConfig { private String SIGNING_KEY = "mq123" ; @Autowired TokenStore tokenStore; @Autowired private JwtAccessTokenConverter accessTokenConverter; @Bean public TokenStore tokenStore () { return new JwtTokenStore (accessTokenConverter()); } @Bean public JwtAccessTokenConverter accessTokenConverter () { JwtAccessTokenConverter converter = new JwtAccessTokenConverter (); converter.setSigningKey(SIGNING_KEY); return converter; } @Bean(name="authorizationServerTokenServicesCustom") public AuthorizationServerTokenServices tokenService () { DefaultTokenServices service=new DefaultTokenServices (); service.setSupportRefreshToken(true ); service.setTokenStore(tokenStore); TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain (); tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter)); service.setTokenEnhancer(tokenEnhancerChain); service.setAccessTokenValiditySeconds(7200 ); service.setRefreshTokenValiditySeconds(259200 ); return service; } }
申请令牌http测试:
1 POST {{auth_host}}/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123
测试资源服务校验令牌
拿到了jwt令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务
在内容管理服务的content-api工程中添加依赖
1 2 3 4 5 6 7 8 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency >
http请求携带jwt令牌访问课程信息
1 Authorization : Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsi...
注意Authorization: Bearer
是规定用来校验的字段,jwt令牌过长结尾省略
测试获取用户身份
1 2 3 4 5 6 7 8 9 @ApiOperation("根据课程id查询课程基础信息") @GetMapping("/course/{courseId}") public CourseBaseInfoDto getCourseBaseById (@PathVariable("courseId") Long courseId) { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); System.out.println(principal); return courseBaseInfoService.getCourseBaseInfo(courseId); }
网关认证
技术方案
到目前为止,测试通过了认证服务颁发jwt令牌,客户端携带jwt访问资源服务,资源服务对jwt的合法性进行验证。如下图:
仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示:
所有访问微服务的请求都要经过网关,在网关进行用户身份的认证可以将很多非法的请求拦截到微服务以外,这叫做网关认证。
下边需要明确网关的职责:
1、网站白名单维护
针对不用认证的URL全部放行。
2、校验jwt的合法性。
除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。
网关负责授权吗?
网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口。
实现网关认证
实现以下职责:
1、网站白名单维护
针对不用认证的URL全部放行。
2、校验jwt的合法性。
除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。
网关工程依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-security</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-oauth2</artifactId > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > fastjson</artifactId > </dependency >
配置白名单文件security-whitelist.properties
1 2 3 4 5 /** =临时全部放行 /auth/** =认证地址 /content/open/** =内容管理公开访问接口 /media/open/** =媒资管理公开访问接口
导入了四个文件,其中最重要的的是GatewayAuthFilter
其内部过滤器比较重要
过滤器代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 package com.xuecheng.gateway.config;import com.alibaba.fastjson.JSON;import lombok.extern.slf4j.Slf4j;import org.apache.commons.lang.StringUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.cloud.gateway.filter.GatewayFilterChain;import org.springframework.cloud.gateway.filter.GlobalFilter;import org.springframework.core.Ordered;import org.springframework.core.io.buffer.DataBuffer;import org.springframework.http.HttpStatus;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.security.oauth2.common.OAuth2AccessToken;import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;import org.springframework.security.oauth2.provider.token.TokenStore;import org.springframework.stereotype.Component;import org.springframework.util.AntPathMatcher;import org.springframework.web.server.ServerWebExchange;import reactor.core.publisher.Mono;import java.io.InputStream;import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.List;import java.util.Properties;import java.util.Set;@Component @Slf4j public class GatewayAuthFilter implements GlobalFilter , Ordered { private static List<String> whitelist = null ; static { try ( InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties" ); ) { Properties properties = new Properties (); properties.load(resourceAsStream); Set<String> strings = properties.stringPropertyNames(); whitelist = new ArrayList <>(strings); } catch (Exception e) { log.error("加载/security-whitelist.properties出错:{}" , e.getMessage()); e.printStackTrace(); } } @Autowired private TokenStore tokenStore; @Override public Mono<Void> filter (ServerWebExchange exchange, GatewayFilterChain chain) { String requestUrl = exchange.getRequest().getPath().value(); AntPathMatcher pathMatcher = new AntPathMatcher (); for ( String url : whitelist ) { if ( pathMatcher.match(url, requestUrl) ) { return chain.filter(exchange); } } String token = getToken(exchange); if ( StringUtils.isBlank(token) ) { return buildReturnMono("没有认证" , exchange); } OAuth2AccessToken oAuth2AccessToken; try { oAuth2AccessToken = tokenStore.readAccessToken(token); boolean expired = oAuth2AccessToken.isExpired(); if ( expired ) { return buildReturnMono("认证令牌已过期" , exchange); } return chain.filter(exchange); } catch (InvalidTokenException e) { log.info("认证令牌无效: {}" , token); return buildReturnMono("认证令牌无效" , exchange); } } private String getToken (ServerWebExchange exchange) { String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization" ); if ( StringUtils.isBlank(tokenStr) ) { return null ; } String token = tokenStr.split(" " )[1 ]; if ( StringUtils.isBlank(token) ) { return null ; } return token; } private Mono<Void> buildReturnMono (String error, ServerWebExchange exchange) { ServerHttpResponse response = exchange.getResponse(); String jsonString = JSON.toJSONString(new RestErrorResponse (error)); byte [] bits = jsonString.getBytes(StandardCharsets.UTF_8); DataBuffer buffer = response.bufferFactory().wrap(bits); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add("Content-Type" , "application/json;charset=UTF-8" ); return response.writeWith(Mono.just(buffer)); } @Override public int getOrder () { return 0 ; } }
网关的三个主要功能:
提供路由功能
提供白名单
校验jwt合法性
用户认证
需求分析
至此我们了解了使用Spring Security进行认证授权的过程,本节实现用户认证功能。
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。
本项目也要支持多种认证
连接用户中心数据库
连接数据库认证
基于的认证流程在研究Spring Security过程中已经测试通过,到目前为止用户认证流程如下:
认证所需要的用户信息存储在用户中心数据库,现在需要将认证服务连接数据库查询用户信息。
在研究Spring Security的过程中是将用户信息硬编码
我们要认证服务中连接用户中心数据库查询用户信息。
如何使用Spring Security连接数据库认证吗?
前边学习Spring Security工作原理时有一张执行流程图,如下图:
sequenceDiagram
actor User
User->>+UsernamePasswordAuthenticationFilter: 1. 用户提交用户名,密码
UsernamePasswordAuthenticationFilter->>UsernamePasswordAuthenticationFilter: 2. 将请求信息封装为Authentication 实现类为UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationFilter->>AuthenticationManager: 3. 认证authenticate()
AuthenticationManager->>+DaoAuthenticationProvider: 4.委托认证authenticate()
DaoAuthenticationProvider->>+UserDetailsService: 5.获取用户信息loadUserByUsername()
UserDetailsService->>-DaoAuthenticationProvider: 6.返回UserDetails
DaoAuthenticationProvider->>DaoAuthenticationProvider: 7.通过PasswordEncoder对比UserDetails中的密码与Authentication中密码是否一致
DaoAuthenticationProvider->>-DaoAuthenticationProvider: 8.填充Authentication,如权限信息
DaoAuthenticationProvider->>UsernamePasswordAuthenticationFilter: 9.返回Authentication
UsernamePasswordAuthenticationFilter->>-SecurityContextHolder: 10.SecurityContextHolder.getContext().setAuthentication(…)方法将Authentication保存至安全上下文
用户提交账号和密码由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。
UserDetailService是一个接口:
1 2 3 4 5 package org.springframework.security.core.userdetails;public interface UserDetailsService { UserDetails loadUserByUsername (String var1) throws UsernameNotFoundException; }
UserDetails是用户信息接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority > getAuthorities(); String getPassword () ; String getUsername () ; boolean isAccountNonExpired () ; boolean isAccountNonLocked () ; boolean isCredentialsNonExpired () ; boolean isEnabled () ; }
我们只要实现UserDetailsService 接口查询数据库得到用户信息返回UserDetails 类型的用户信息即可,框架调用loadUserByUsername()方法拿到用户信息之后是如何执行的,见下图:
首先屏蔽原有的UserService
然后新建软件包写下如下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class UserServiceImpl implements UserDetailsService { @Autowired XcUserMapper xcUserMapper; @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, s)); if ( xcUser == null ) { return null ; } String password = xcUser.getPassword(); String[] authorities = {"test" }; UserDetails userDetails = User .builder() .username(xcUser.getUsername()) .password(password) .authorities(authorities).build(); return userDetails; } }
数据库中的密码加过密的,用户输入的密码是明文,我们需要修改密码格式器PasswordEncoder,原来使用的是NoOpPasswordEncoder,它是通过明文方式比较密码,现在我们修改为BCryptPasswordEncoder,它是将用户输入的密码编码为BCrypt格式与数据库中的密码进行比对。
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) { String password = "123456" ; PasswordEncoder passwordEncoder = new BCryptPasswordEncoder (); for ( int i = 0 ; i < 5 ; i++ ) { String encode = passwordEncoder.encode(password); System.out.println(encode); System.out.println(passwordEncoder.matches(password, encode)); } }
修改数据库中的密码为Bcrypt格式,并且记录明文密码,稍后申请令牌时需要。
由于修改密码编码方式还需要将客户端的密钥更改为Bcrypt格式.
在AuthorizationServer中修改
扩展用户身份信息
我们需要扩展用户身份的信息,在jwt令牌中存储用户的昵称、头像、qq等信息。
如何扩展Spring Security的用户身份信息呢?
在认证阶段DaoAuthenticationProvider会调用UserDetailService查询用户的信息,这里是可以获取到齐全的用户信息的。由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路:
是可以扩展UserDetails,使之包括更多的自定义属性
也可以扩展username的内容 ,比如存入json数据内容作为username的内容。相比较而言,方案二比较简单还不用破坏UserDetails的结构,我们采用方案二。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, s)); if ( xcUser == null ) { return null ; } String password = xcUser.getPassword(); String[] authorities = {"test" }; xcUser.setPassword(null ); String userJson = JSON.toJSONString(xcUser); UserDetails userDetails = User .builder() .username(userJson) .password(password) .authorities(authorities).build(); return userDetails; }
密码置空可以保证安全性
在资源服务中就可以通过SecurityUtil
工具类获取用户信息
支持认证方式多样化
统一认证入口
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。基于当前研究的Spring Security认证流程如何支持多样化的认证方式呢?
1、支持账号和密码认证
采用OAuth2协议的密码模式即可实现。
2、支持手机号加验证码认证
用户认证提交的是手机号和验证码,并不是账号和密码。
3、微信扫码认证
基于OAuth2协议与微信交互,学成在线网站向微信服务器申请到一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过。
目前我们测试通过OAuth2的密码模式,用户认证会提交账号和密码,由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。
在前边我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息。
而不同的认证方式提交的数据不一样,比如:手机加验证码方式会提交手机号和验证码,账号密码方式会提交账号、密码、验证码。
我们可以在loadUserByUsername()方法上作文章,将用户原来提交的账号数据改为提交json数据,json数据可以扩展不同认证方式所提交的各种参数。
首先创建一个DTO类表示认证的参数:
1 2 3 4 5 6 7 8 9 10 11 @Data public class AuthParamsDto { private String username; private String password; private String cellphone; private String checkcode; private String checkcodekey; private String authType; private Map<String, Object> payload = new HashMap <>(); }
UserServiceImpl修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public UserDetails loadUserByUsername (String s) throws UsernameNotFoundException { AuthParamsDto authParamsDto = null ; try { authParamsDto = JSON.parseObject(s, AuthParamsDto.class); } catch (Exception e) { log.info("认证请求不符合项目要求:{}" ,s); throw new RuntimeException ("认证请求数据格式不对" ); } String username = authParamsDto.getUsername(); XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if (user==null ){ return null ; } String password = user.getPassword(); String[] authorities = {"p1" }; String userString = JSON.toJSONString(user); UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build(); return userDetails; }
原来的DaoAuthenticationProvider 会进行密码校验,现在重新定义DaoAuthenticationProviderCustom类,重写类的additionalAuthenticationChecks方法。因为有一些方式,比如微信扫码
和手机验证码
不需要校验密码 ,所以我们自定义校验方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Slf4j @Component public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider { @Autowired public void setUserDetailsService (UserDetailsService userDetailsService) { super .setUserDetailsService(userDetailsService); } protected void additionalAuthenticationChecks (UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } }
在WebSecurityConfig中注入DaoAuthenticationProviderCustom
1 2 3 4 5 6 7 @Autowired DaoAuthenticationProviderCustom daoAuthenticationProviderCustom; @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(daoAuthenticationProviderCustom); }
此时可以重启认证服务,测试申请令牌接口,传入的账号信息改为json数据,如下:
1 2 3 ################扩展认证请求参数后###################### ###密码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"stu1","authType":"password","password":"111111"}
定义统一认证接口
1 2 3 4 5 6 7 8 9 10 public interface AuthService { XcUserExt execute (AuthParamsDto authParamsDto) ; }
定义账号密码认证接口:
1 2 3 4 5 6 7 @Service("password_authService") public class PasswordAuthServiceImpl implements AuthService { @Override public XcUserExt execute (AuthParamsDto authParamsDto) { return null ; } }
定义微信扫码认证接口:
1 2 3 4 5 6 7 @Service("wx_authService") public class WxAuthServiceImpl implements AuthService { @Override public XcUserExt execute (AuthParamsDto authParamsDto) { return null ; } }
实现账号密码认证
账号密码实现类代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Service("password_authService") public class PasswordAuthServiceImpl implements AuthService { @Autowired XcUserMapper xcUserMapper; @Autowired PasswordEncoder passwordEncoder; @Autowired CheckCodeClient checkCodeClient; @Override public XcUserExt execute (AuthParamsDto authParamsDto) { String username = authParamsDto.getUsername(); XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if ( xcUser == null ) { throw new RuntimeException ("账号不存在" ); } String passwordDB = xcUser.getPassword(); String passwordForm = authParamsDto.getPassword(); boolean matches = passwordEncoder.matches(passwordForm, passwordDB); if ( !matches ) { throw new RuntimeException ("账号或密码错误" ); } XcUserExt xcUserExt = new XcUserExt (); BeanUtils.copyProperties(xcUser, xcUserExt); return xcUserExt; } }
验证码服务
验证码作用
在认证时一般都需要输入验证码,验证码有什么用?
验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。
为了保护系统的安全在一些比较重要的操作都需要验证码。
验证码接口测试
验证码服务对外提供的接口有:
1、生成验证码
2、校验验证码。
如下:
验证码服务如何生成并校验验证码?
拿图片验证码举例:
1、先生成一个指定位数的验证码,根据需要可能是数字、数字字母组合或文字。
2、根据生成的验证码生成一个图片并返回给页面
3、给生成的验证码分配一个key,将key和验证码一同存入缓存。这个key和图片一同返回给页面。
4、用户输入验证码,连同key一同提交至认证服务。
5、认证服务拿key和输入的验证码请求验证码服务去校验
6、验证码服务根据key从缓存取出正确的验证码和用户输入的验证码进行比对,如果相同则校验通过,否则不通过。
1、生成验证码接口
1 2 ### 申请验证码 POST {{checkcode_host}}/checkcode/pic
2、校验验证码接口
根据生成验证码返回的key以及日志中输出正确的验证码去测试。
1 2 ### 校验验证码 POST {{checkcode_host}}/checkcode/verify?key=checkcode4506b95bddbe46cdb0d56810b747db1b&code=70dl
账号密码认证
需求分析
到目前为止账号和密码认证所需要的技术、组件都已开发完毕,下边实现账号密码认证,输出如下图:
流程如下:
账号密码认证开发
在认证服务定义远程调用验证码服务的接口
完善PasswordAuthServiceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 @Service("password_authService") public class PasswordAuthServiceImpl implements AuthService { @Autowired XcUserMapper xcUserMapper; @Autowired PasswordEncoder passwordEncoder; @Autowired CheckCodeClient checkCodeClient; @Override public XcUserExt execute (AuthParamsDto authParamsDto) { String username = authParamsDto.getUsername(); String checkcode = authParamsDto.getCheckcode(); String checkcodekey = authParamsDto.getCheckcodekey(); if ( StringUtils.isEmpty(checkcodekey) || StringUtils.isEmpty(checkcode) ) { throw new RuntimeException ("验证码为空" ); } Boolean verify = checkCodeClient.verify(checkcodekey, checkcode); if ( !verify ) { throw new RuntimeException ("验证码输入错误" ); } XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if ( xcUser == null ) { throw new RuntimeException ("账号不存在" ); } String passwordDB = xcUser.getPassword(); String passwordForm = authParamsDto.getPassword(); boolean matches = passwordEncoder.matches(passwordForm, passwordDB); if ( !matches ) { throw new RuntimeException ("账号或密码错误" ); } XcUserExt xcUserExt = new XcUserExt (); BeanUtils.copyProperties(xcUser, xcUserExt); return xcUserExt; } }
微信扫码登录
接入规范
接入流程
微信扫码登录基于OAuth2协议的授权码模式,
接口文档:
https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
流程如下:
第三方应用获取access_token令牌后即可请求微信获取用户的信息,成功获取到用户的信息表示用户在第三方应用认证成功。
请求获取授权码
第三方使用网站应用授权登录前请注意已获取相应网页授权作用域(scope=snsapi_login)
则可以通过在 PC 端打开以下链接: https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
若提示“该链接无法访问”,请检查参数是否填写错误,如redirect_uri的域名与审核时填写的授权域名不一致或 scope 不为snsapi_login。
返回说明
用户允许授权后,将会重定向到redirect_uri的网址上,并且带上 code 和state参数
1 redirect_uri?code=CODE&state=STATE
若用户禁止授权,则不会发生重定向。
登录一号店网站应用 https://test.yhd.com/wechat/login.do 打开后,一号店会生成 state 参数
跳转到 https://open.weixin.qq.com/connect/qrconnect?appid=wxbdc5610cc59c1631&redirect_uri=https%3A%2F%2Fpassport.yhd.com%2Fwechat%2Fcallback.do&response_type=code&scope=snsapi_login&state=3d6be0a4035d839573b04816624a415e#wechat_redirect
微信用户使用微信扫描二维码并且确认登录后
PC端会跳转到 https://test.yhd.com/wechat/callback.do?code=CODE&state=3d6be0a40sssssxxxxx6624a415e 为了满足网站更定制化的需求,我们还提供了第二种获取 code 的方式,支持网站将微信登录二维码内嵌到自己页面中,用户使用微信扫码授权后通过 JS 将code返回给网站。 JS微信登录主要用途:网站希望用户在网站内就能完成登录,无需跳转到微信域下登录后再返回,提升微信登录的流畅性与成功率。 网站内嵌二维码微信登录 JS 实现办法:
步骤1:在页面中先引入如下 JS 文件(支持https):
1 http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
步骤2:在需要使用微信登录的地方实例以下 JS 对象:
1 2 3 4 5 6 7 8 9 10 var obj = new WxLogin ({ self_redirect :true , id :"login_container" , appid : "" , scope : "" , redirect_uri : "" , state : "" , style : "" , href : "" });
通过 code 获取access_token
1 https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
正确返回:
1 2 3 4 5 6 7 8 9 { "access_token" : "ACCESS_TOKEN" , "expires_in" : 7200 , "refresh_token" : "REFRESH_TOKEN" , "openid" : "OPENID" , "scope" : "SCOPE" , "unionid" : "o6_bmasdasdsad6_2sgVt7hMZOPfL" }
错误返回:
1 2 3 4 { "errcode" : 40029 , "errmsg" : "invalid code" }
通过access_token调用接口
1 2 access_token有效且未超时; 微信用户已授权给第三方应用帐号相应接口作用域(scope)。
获取用户信息接口文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Authorized_Interface_Calling_UnionID.html
接口地址:http请求方式: GET
https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID
正确响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "openid" : "OPENID" , "nickname" : "NICKNAME" , "sex" : 1 , "province" : "PROVINCE" , "city" : "CITY" , "country" : "COUNTRY" , "headimgurl" : "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0" , "privilege" : [ "PRIVILEGE1" , "PRIVILEGE2" ] , "unionid" : " o6_bmasdasdsad6_2sgVt7hMZOPfL" }
微信登录部分的请求只能用别人的
1 2 3 weixin: appid: wxed9954c01bb89b47 secret: a7482517235173ddb4083788de60b90e
在html文件中修改
1 2 3 4 5 6 7 8 9 10 var wxObj = new WxLogin ({ self_redirect :true , id :"login_container" , appid : "wxed9954c01bb89b47" , scope : "snsapi_login" , redirect_uri : "http://localhost:8160/auth/wxLogin" , state : token, style : "" , href : "" });
接入微信登录
接入分析
本部分内容涉及到的请求url请查看微信帮助文档
本项目认证服务需要做哪些事?
1、需要定义接口接收微信下发的授权码。
2、收到授权码调用微信接口申请令牌。
3、申请到令牌调用微信获取用户信息
4、获取用户信息成功将其写入本项目用户中心数据库。
5、最后重定向到浏览器自动登录。
定义接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Slf4j @Controller public class WxLoginController { @RequestMapping("/wxLogin") public String wxLogin (String code, String state) throws IOException { log.debug("微信扫码回调,code:{},state:{}" , code, state); XcUser xcUser = new XcUser (); xcUser.setUsername("t1" ); if ( xcUser == null ) { return "redirect:http://www.51xuecheng.cn/error.html" ; } String username = xcUser.getUsername(); return "redirect:http://www.51xuecheng.cn/sign.html?username=" + username + "&authType=wx" ; } }
接入微信认证
使用restTemplate请求微信,配置RestTemplate bean
在启动类配置restTemplate
1 2 3 4 5 @Bean RestTemplate restTemplate () { RestTemplate restTemplate = new RestTemplate (new OkHttp3ClientHttpRequestFactory ()); return restTemplate; }
定义与微信认证的service接口:
1 2 3 public interface WxAuthService { public XcUser wxAuth (String code) ; }
远程调用微信url获取令牌:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 private Map<String, String> getAccess_token (String code) { String wxUrl_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code" ; String wxUrl = String.format(wxUrl_template, appid, secret, code); ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null , String.class); String result = exchange.getBody(); Map<String,String> map = JSON.parseObject(result, Map.class); return map; }
根据令牌获取用户信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 private Map<String,String> getUserinfo (String access_token,String openid) { String wxUrl_template = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s" ; String wxUrl = String.format(wxUrl_template, access_token, openid); ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.GET, null , String.class); String result = new String (exchange.getBody().getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8); Map<String,String> map = JSON.parseObject(result, Map.class); return map; }
数据库保存用户信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Transactional public XcUser addWxUser (Map<String,String> userInfo_map) { String nickname = userInfo_map.get("nickname" ); String unionid = userInfo_map.get("unionid" ); XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getWxUnionid, unionid)); if (xcUser != null ) { return xcUser; } String userId = UUID.randomUUID().toString(); xcUser = new XcUser (); xcUser.setNickname(userInfo_map.get("nickname" )); xcUser.setUserpic(userInfo_map.get("headimgurl" )); xcUser.setName(userInfo_map.get("nickname" )); xcUser.setUsername(unionid); xcUser.setPassword(unionid); xcUser.setUtype("101001" ); xcUser.setStatus("1" ); xcUser.setCreateTime(LocalDateTime.now()); xcUserMapper.insert(xcUser); XcUserRole xcUserRole = new XcUserRole (); xcUserRole.setId(UUID.randomUUID().toString()); xcUserRole.setUserId(userId); xcUserRole.setRoleId("17" ); xcUserRoleMapper.insert(xcUserRole); return xcUser; }
controller调用微信扫码认证方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Override public XcUser wxAuth (String code) { Map<String, String> accessToken = getAccess_token(code); String access_token = accessToken.get("access_token" ); String openid = accessToken.get("openid" ); Map<String, String> userinfo = getUserinfo(access_token, openid); XcUser xcUser = currentProxy.addWxUser(userinfo); return xcUser; }
excute验证方法:
1 2 3 4 5 6 7 8 9 10 11 12 @Override public XcUserExt execute (AuthParamsDto authParamsDto) { String username = authParamsDto.getUsername(); XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper <XcUser>().eq(XcUser::getUsername, username)); if (xcUser == null ){ throw new RuntimeException ("账号不存在" ); } XcUserExt xcUserExt = new XcUserExt (); BeanUtils.copyProperties(xcUser, xcUserExt); return xcUserExt; }
下边在controller中调用wxAuth接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Slf4j @Controller public class WxLoginController { @Autowired WxAuthService wxAuthService; @RequestMapping("/wxLogin") public String wxLogin (String code, String state) throws IOException { log.debug("微信扫码回调,code:{},state:{}" ,code,state); XcUser xcUser = wxAuthService.wxAuth(code); if (xcUser==null ){ return "redirect:http://www.51xuecheng.cn/error.html" ; } String username = xcUser.getUsername(); return "redirect:http://www.51xuecheng.cn/sign.html?username=" +username+"&authType=wx" ; } }