基于 redis 的小程序登录实现
作者:gigass
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
** 你好, 这是我的第一篇博客.
因为前段时间做过一个小程序, 所以去学习了一下小程序的登录流程. 废话不多说, 下面附上我的学习结果.**
这张图是小程序的登录流程解析:
小程序登陆授权流程:
- 在小程序端调用 wx.login()获取 code, 由于我是做后端开发的这边不做赘述, 直接贴上代码了. 有兴趣的直接去官方文档看下, 链接放这里: wx.login()
wx.login({success (res) {if (res.code) {
// 发起网络请求
wx.request({
url: 'https://test.com/onLogin',
data: {code: res.code}
})
} else {console.log('登录失败!' + res.errMsg)
}
}
})
小程序前端登录后会获取 code, 调用自己的开发者服务接口, 调用个 get 请求:
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
需要得四个参数:
appid:小程序 appid
secret:小程序密钥
js_code:刚才获取的 code
grant_type:’authorization_code’ // 这个是固定的
如果不出意外的话, 微信接口服务器会返回四个参数:
详情可以看下官方文档: jscode2session
下面附上我的代码:
@AuthIgnore
@RequestMapping("/login")
@ResponseBody
public ResponseBean openId(@RequestParam(value = "code", required = true) String code,
@RequestParam(value = "avatarUrl") String avatarUrl,
@RequestParam(value = "city") String city,
@RequestParam(value = "country") String country,
@RequestParam(value = "gender") String gender,
@RequestParam(value = "language") String language,
@RequestParam(value = "nickName") String nickName,
@RequestParam(value = "province") String province,
HttpServletRequest request) { // 小程序端获取的 CODE
ResponseBean responseBean = new ResponseBean();
try {boolean check = (StringUtils.isEmpty(code)) ? true : false;
if (check) {responseBean = new ResponseBean(false, UnicomResponseEnums.NO_CODE);
return responseBean;
}
// 将获取的用户数据存入数据库;
Map<String, Object> msgs = new HashMap<>();
msgs.put("appid", appId);
msgs.put("secret", secret);
msgs.put("js_code", code);
msgs.put("grant_type", "authorization_code");
// java 的网络请求,返回字符串
String data = HttpUtils.get(msgs, Constants.JSCODE2SESSION);
logger.info("======>" + data);
String openId = JSONObject.parseObject(data).getString("openid");
String session_key = JSONObject.parseObject(data).getString("session_key");
String unionid = JSONObject.parseObject(data).getString("unionid");
String errcode = JSONObject.parseObject(data).getString("errcode");
String errmsg = JSONObject.parseObject(data).getString("errmsg");
JSONObject json = new JSONObject();
int userId = -1;
if (!StringUtils.isBlank(openId)) {Users user = userService.selectUserByOpenId(openId);
if (user == null) {
// 新建一个用户信息
Users newUser = new Users();
newUser.setOpenid(openId);
newUser.setArea(city);
newUser.setSex(Integer.parseInt(gender));
newUser.setNickName(nickName);
newUser.setCreateTime(new Date());
newUser.setStatus(0);// 初始
if (!StringUtils.isBlank(unionid)) {newUser.setUnionid(unionid);
}
userService.instert(newUser);
userId = newUser.getId();} else {userId = user.getId();
}
json.put("userId", userId);
}
if (!StringUtils.isBlank(session_key) && !StringUtils.isBlank(openId)) {
// 这段可不用 redis 存, 直接返回 session_key 也可以
String userAgent = request.getHeader("user-agent");
String sessionid = tokenService.generateToken(userAgent, session_key);
// 将 session_key 存入 redis
redisUtil.setex(sessionid, session_key + "###" + userId, Constants.SESSION_KEY_EX);
json.put("token", sessionid);
responseBean = new ResponseBean(true, json);
} else {responseBean = new ResponseBean<>(false, null, errmsg);
}
return responseBean;
} catch (Exception e) {e.printStackTrace();
responseBean = new ResponseBean(false, UnicomResponseEnums.JSCODE2SESSION_ERRO);
return responseBean;
}
}
解析:
这边我的登录获取的 session_key 出于安全性考虑没有直接在前端传输, 而是存到了 redis 里面给到前端 session_key 的 token 传输,
而且 session_key 的销毁时间是 20 分钟, 时间内可以重复获取用户数据.
如果只是简单使用或者对安全性要求不严的话可以直接传 session_key 到前端保存.
session_key 的作用:
** 校验用户信息 (wx.getUserInfo(OBJECT) 返回的 signature);
解密 (wx.getUserInfo(OBJECT) 返回的 encryptedData);**
按照官方的说法,wx.checksession 是用来检查 wx.login(OBJECT) 的时效性,判断登录是否过期;
疑惑的是(openid,unionid)都是用户唯一标识,不会因为 wx.login(OBJECT)的过期而改变,所以要是没有使用 wx.getUserInfo(OBJECT)获得的用户信息,确实没必要使用 wx.checksession()来检查 wx.login(OBJECT) 是否过期;
如果使用了 wx.getUserInfo(OBJECT)获得的用户信息,还是有必要使用 wx.checksession()来检查 wx.login(OBJECT) 是否过期的,因为用户有可能修改了头像、昵称、城市,省份等信息,可以通过检查 wx.login(OBJECT) 是否过期来更新着些信息;
小程序的登录状态维护本质就是维护 session_key 的时效性
这边附上我的 HttpUtils 工具代码, 如果只要用 get 的话可以复制部分:
/**
* HttpUtils 工具类
*
* @author
*/
public class HttpUtils {
/**
* 请求方式:post
*/
public static String POST = "post";
/**
* 编码格式:utf-8
*/
private static final String CHARSET_UTF_8 = "UTF-8";
/**
* 报文头部 json
*/
private static final String APPLICATION_JSON = "application/json";
/**
* 请求超时时间
*/
private static final int CONNECT_TIMEOUT = 60 * 1000;
/**
* 传输超时时间
*/
private static final int SO_TIMEOUT = 60 * 1000;
/**
* 日志
*/
private static final Logger logger = LoggerFactory.getLogger(HttpUtils.class);
/**
* @param protocol
* @param url
* @param paraMap
* @return
* @throws Exception
*/
public static String doPost(String protocol, String url,
Map<String, Object> paraMap) throws Exception {
CloseableHttpClient httpClient = null;
CloseableHttpResponse resp = null;
String rtnValue = null;
try {if (protocol.equals("http")) {httpClient = HttpClients.createDefault();
} else {
// 获取 https 安全客户端
httpClient = HttpUtils.getHttpsClient();}
HttpPost httpPost = new HttpPost(url);
List<NameValuePair> list = msgs2valuePairs(paraMap);
// List<NameValuePair> list = new ArrayList<NameValuePair>();
// if (null != paraMap &¶Map.size() > 0) {// for (Entry<String, Object> entry : paraMap.entrySet()) {// list.add(new BasicNameValuePair(entry.getKey(), entry
// .getValue().toString()));
// }
// }
RequestConfig requestConfig = RequestConfig.custom()
.setSocketTimeout(SO_TIMEOUT)
.setConnectTimeout(CONNECT_TIMEOUT).build();// 设置请求和传输超时时间
httpPost.setConfig(requestConfig);
httpPost.setEntity(new UrlEncodedFormEntity(list, CHARSET_UTF_8));
resp = httpClient.execute(httpPost);
rtnValue = EntityUtils.toString(resp.getEntity(), CHARSET_UTF_8);
} catch (Exception e) {logger.error(e.getMessage());
throw e;
} finally {if (null != resp) {resp.close();
}
if (null != httpClient) {httpClient.close();
}
}
return rtnValue;
}
/**
* 获取 https,单向验证
*
* @return
* @throws Exception
*/
public static CloseableHttpClient getHttpsClient() throws Exception {
try {TrustManager[] trustManagers = new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] paramArrayOfX509Certificate,
String paramString) throws CertificateException { }
public void checkServerTrusted(X509Certificate[] paramArrayOfX509Certificate,
String paramString) throws CertificateException { }
public X509Certificate[] getAcceptedIssuers() {return null;}
}};
SSLContext sslContext = SSLContext
.getInstance(SSLConnectionSocketFactory.TLS);
sslContext.init(new KeyManager[0], trustManagers,
new SecureRandom());
SSLContext.setDefault(sslContext);
sslContext.init(null, trustManagers, null);
SSLConnectionSocketFactory connectionSocketFactory = new SSLConnectionSocketFactory(
sslContext,
SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
HttpClientBuilder clientBuilder = HttpClients.custom()
.setSSLSocketFactory(connectionSocketFactory);
clientBuilder.setRedirectStrategy(new LaxRedirectStrategy());
CloseableHttpClient httpClient = clientBuilder.build();
return httpClient;
} catch (Exception e) {throw new Exception("http client 远程连接失败", e);
}
}
/**
* post 请求
*
* @param msgs
* @param url
* @return
* @throws ClientProtocolException
* @throws UnknownHostException
* @throws IOException
*/
public static String post(Map<String, Object> msgs, String url)
throws ClientProtocolException, UnknownHostException, IOException {CloseableHttpClient httpClient = HttpClients.createDefault();
try {HttpPost request = new HttpPost(url);
List<NameValuePair> valuePairs = msgs2valuePairs(msgs);
// List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
// if (null != msgs) {// for (Entry<String, Object> entry : msgs.entrySet()) {// if (entry.getValue() != null) {// valuePairs.add(new BasicNameValuePair(entry.getKey(),
// entry.getValue().toString()));
// }
// }
// }
request.setEntity(new UrlEncodedFormEntity(valuePairs, CHARSET_UTF_8));
CloseableHttpResponse resp = httpClient.execute(request);
return EntityUtils.toString(resp.getEntity(), CHARSET_UTF_8);
} finally {httpClient.close();
}
}
/**
* post 请求
*
* @param msgs
* @param url
* @return
* @throws ClientProtocolException
* @throws UnknownHostException
* @throws IOException
*/
public static byte[] postGetByte(Map<String, Object> msgs, String url)
throws ClientProtocolException, UnknownHostException, IOException {CloseableHttpClient httpClient = HttpClients.createDefault();
InputStream inputStream = null;
byte[] data = null;
try {HttpPost request = new HttpPost(url);
List<NameValuePair> valuePairs = msgs2valuePairs(msgs);
// List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
// if (null != msgs) {// for (Entry<String, Object> entry : msgs.entrySet()) {// if (entry.getValue() != null) {// valuePairs.add(new BasicNameValuePair(entry.getKey(),
// entry.getValue().toString()));
// }
// }
// }
request.setEntity(new UrlEncodedFormEntity(valuePairs, CHARSET_UTF_8));
CloseableHttpResponse response = httpClient.execute(request);
try {
// 获取相应实体
HttpEntity entity = response.getEntity();
if (entity != null) {inputStream = entity.getContent();
data = readInputStream(inputStream);
}
return data;
} catch (Exception e) {e.printStackTrace();
} finally {httpClient.close();
return null;
}
} finally {httpClient.close();
}
}
/** 将流 保存为数据数组
* @param inStream
* @return
* @throws Exception
*/
public static byte[] readInputStream(InputStream inStream) throws Exception {ByteArrayOutputStream outStream = new ByteArrayOutputStream();
// 创建一个 Buffer 字符串
byte[] buffer = new byte[1024];
// 每次读取的字符串长度,如果为 -1,代表全部读取完毕
int len = 0;
// 使用一个输入流从 buffer 里把数据读取出来
while ((len = inStream.read(buffer)) != -1) {
// 用输出流往 buffer 里写入数据,中间参数代表从哪个位置开始读,len 代表读取的长度
outStream.write(buffer, 0, len);
}
// 关闭输入流
inStream.close();
// 把 outStream 里的数据写入内存
return outStream.toByteArray();}
/**
* get 请求
*
* @param msgs
* @param url
* @return
* @throws ClientProtocolException
* @throws UnknownHostException
* @throws IOException
*/
public static String get(Map<String, Object> msgs, String url)
throws ClientProtocolException, UnknownHostException, IOException {CloseableHttpClient httpClient = HttpClients.createDefault();
try {List<NameValuePair> valuePairs = msgs2valuePairs(msgs);
// List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
// if (null != msgs) {// for (Entry<String, Object> entry : msgs.entrySet()) {// if (entry.getValue() != null) {// valuePairs.add(new BasicNameValuePair(entry.getKey(),
// entry.getValue().toString()));
// }
// }
// }
// EntityUtils.toString(new UrlEncodedFormEntity(valuePairs),
// CHARSET);
url = url + "?" + URLEncodedUtils.format(valuePairs, CHARSET_UTF_8);
HttpGet request = new HttpGet(url);
CloseableHttpResponse resp = httpClient.execute(request);
return EntityUtils.toString(resp.getEntity(), CHARSET_UTF_8);
} finally {httpClient.close();
}
}
public static <T> T post(Map<String, Object> msgs, String url,
Class<T> clazz) throws ClientProtocolException,
UnknownHostException, IOException {String json = HttpUtils.post(msgs, url);
T t = JSON.parseObject(json, clazz);
return t;
}
public static <T> T get(Map<String, Object> msgs, String url, Class<T> clazz)
throws ClientProtocolException, UnknownHostException, IOException {String json = HttpUtils.get(msgs, url);
T t = JSON.parseObject(json, clazz);
return t;
}
public static String postWithJson(Map<String, Object> msgs, String url)
throws ClientProtocolException, IOException {CloseableHttpClient httpClient = HttpClients.createDefault();
try {String jsonParam = JSON.toJSONString(msgs);
HttpPost post = new HttpPost(url);
post.setHeader("Content-Type", APPLICATION_JSON);
post.setEntity(new StringEntity(jsonParam, CHARSET_UTF_8));
CloseableHttpResponse response = httpClient.execute(post);
return new String(EntityUtils.toString(response.getEntity()).getBytes("iso8859-1"), CHARSET_UTF_8);
} finally {httpClient.close();
}
}
public static <T> T postWithJson(Map<String, Object> msgs, String url, Class<T> clazz) throws ClientProtocolException,
UnknownHostException, IOException {String json = HttpUtils.postWithJson(msgs, url);
T t = JSON.parseObject(json, clazz);
return t;
}
public static List<NameValuePair> msgs2valuePairs(Map<String, Object> msgs) {List<NameValuePair> valuePairs = new ArrayList<NameValuePair>();
if (null != msgs) {for (Entry<String, Object> entry : msgs.entrySet()) {if (entry.getValue() != null) {valuePairs.add(new BasicNameValuePair(entry.getKey(),
entry.getValue().toString()));
}
}
}
return valuePairs;
}
}
如果是直接传 session_key 到前端的, 下面的可以不用看了.
如果是 redis 存的 sesssion_key 的 token 的话, 这边附上登陆的时候的 token 转换为 session_key.
自定义拦截器注解:
import java.lang.annotation.*;
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuthIgnore {}
拦截器部分代码:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
AuthIgnore annotation;
if(handler instanceof HandlerMethod) {annotation = ((HandlerMethod) handler).getMethodAnnotation(AuthIgnore.class);
}else{return true;}
// 如果有 @AuthIgnore 注解,则不验证 token
if(annotation != null){return true;}
// // 获取微信 access_token;
// if(!redisUtil.exists(Constants.ACCESS_TOKEN)){// Map<String, Object> msgs = new HashMap<>();
// msgs.put("appid",appId);
// msgs.put("secret",secret);
// msgs.put("grant_type","client_credential");
// String data = HttpUtils.get(msgs,Constants.GETACCESSTOKEN); // java 的网络请求,返回字符串
// String errcode= JSONObject.parseObject(data).getString("errcode");
// String errmsg= JSONObject.parseObject(data).getString("errmsg");
// if(StringUtils.isBlank(errcode)){
// // 存储 access_token
// String access_token= JSONObject.parseObject(data).getString("access_token");
// long expires_in=Long.parseLong(JSONObject.parseObject(data).getString("expires_in"));
// redisUtil.setex("ACCESS_TOKEN",access_token, expires_in);
// }else{
// // 异常返回数据拦截,返回 json 数据
// response.setCharacterEncoding("UTF-8");
// response.setContentType("application/json; charset=utf-8");
// PrintWriter out = response.getWriter();
// ResponseBean<Object> responseBean=new ResponseBean<>(false,null, errmsg);
// out = response.getWriter();
// out.append(JSON.toJSON(responseBean).toString());
// return false;
// }
// }
// 获取用户凭证
String token = request.getHeader(Constants.USER_TOKEN);
// if(StringUtils.isBlank(token)){// token = request.getParameter(Constants.USER_TOKEN);
// }
// if(StringUtils.isBlank(token)){// Object obj = request.getAttribute(Constants.USER_TOKEN);
// if(null!=obj){// token=obj.toString();
// }
// }
// //token 凭证为空
// if(StringUtils.isBlank(token)){
// //token 不存在拦截,返回 json 数据
// response.setCharacterEncoding("UTF-8");
// response.setContentType("application/json; charset=utf-8");
// PrintWriter out = response.getWriter();
// try{// ResponseBean<Object> responseBean=new ResponseBean<>(false,null, UnicomResponseEnums.TOKEN_EMPTY);
// out = response.getWriter();
// out.append(JSON.toJSON(responseBean).toString());
// return false;
// }
// catch (Exception e) {// e.printStackTrace();
// response.sendError(500);
// return false;
// }
// }
if(token==null||!redisUtil.exists(token)){
// 用户未登录,返回 json 数据
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter out = response.getWriter();
try{ResponseBean<Object> responseBean=new ResponseBean<>(false,null, UnicomResponseEnums.DIS_LOGIN);
out = response.getWriter();
out.append(JSON.toJSON(responseBean).toString());
return false;
}
catch (Exception e) {e.printStackTrace();
response.sendError(500);
return false;
}
}
tokenManager.refreshUserToken(token);
return true;
}
}
过滤器配置:
@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {
@Override
public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(authorizationInterceptor())
.addPathPatterns("/**")// 拦截所有请求,通过判断是否有 @AuthIgnore 注解 决定是否需要登录
.excludePathPatterns("/user/login");// 排除登录
}
@Bean
public AuthorizationInterceptor authorizationInterceptor() {return new AuthorizationInterceptor();
}
}
token 管理:
@Service
public class TokenManager {
@Resource
private RedisUtil redisUtil;
// 生成 token(格式为 token: 设备 - 加密的用户名 - 时间 - 六位随机数)
public String generateToken(String userAgentStr, String username) {StringBuilder token = new StringBuilder("token:");
// 设备
UserAgent userAgent = UserAgent.parseUserAgentString(userAgentStr);
if (userAgent.getOperatingSystem().isMobileDevice()) {token.append("MOBILE-");
} else {token.append("PC-");
}
// 加密的用户名
token.append(MD5Utils.MD5Encode(username) + "-");
// 时间
token.append(new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()) + "-");
// 六位随机字符串
token.append(UUID.randomUUID().toString());
System.out.println("token-->" + token.toString());
return token.toString();}
/**
* 登录用户,创建 token
*
* @param token
*/
// 把 token 存到 redis 中
public void save(String token, Users user) {if (token.startsWith("token:PC")) {redisUtil.setex(token,JSON.toJSONString(user), Constants.TOKEN_EX);
} else {redisUtil.setex(token,JSON.toJSONString(user), Constants.TOKEN_EX);
}
}
/**
* 刷新用户
*
* @param token
*/
public void refreshUserToken(String token) {if (redisUtil.exists(token)) {String value=redisUtil.get(token);
redisUtil.setex(token, value, Constants.TOKEN_EX);
}
}
/**
* 用户退出登陆
*
* @param token
*/
public void loginOut(String token) {redisUtil.remove(token);
}
/**
* 获取用户信息
*
* @param token
* @return
*/
public Users getUserInfoByToken(String token) {if (redisUtil.exists(token)) {String jsonString = redisUtil.get(token);
Users user =JSONObject.parseObject(jsonString, Users.class);
return user;
}
return null;
}
}
redis 工具类:
@Component
public class RedisUtil {
@Resource
private RedisTemplate<String, String> redisTemplate;
public void set(String key, String value) {ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
valueOperations.set(key, value);
}
public void setex(String key, String value, long seconds) {ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
valueOperations.set(key, value, seconds,TimeUnit.SECONDS);
}
public Boolean exists(String key) {return redisTemplate.hasKey(key);
}
public void remove(String key) {redisTemplate.delete(key);
}
public String get(String key) {ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
return valueOperations.get(key);
}
}
最后 redis 要实现序列化, 序列化最终的目的是为了对象可以跨平台存储,和进行网络传输。而我们进行跨平台存储和网络传输的方式就是 IO,而我们的 IO 支持的数据格式就是字节数组。本质上存储和网络传输 都需要经过 把一个对象状态保存成一种跨平台识别的字节格式,然后其他的平台才可以通过字节信息解析还原对象信息。
@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
public class RedisConfig {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplate<Object, Object> template = new RedisTemplate<>();
// 使用 fastjson 序列化
FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);
// value 值的序列化采用 fastJsonRedisSerializer
template.setValueSerializer(fastJsonRedisSerializer);
template.setHashValueSerializer(fastJsonRedisSerializer);
// key 的序列化采用 StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Bean
@ConditionalOnMissingBean(StringRedisTemplate.class)
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}