登录流程
流程概述
微应用通过
mos.login()
获取登录凭证 code- code 每次调用均不同
- 有效时间为 5 分钟
- code 被 Mos 接口服务验证一次就会失效
微应用获取到 code 后,将 code 发送到微应用后端服务
微应用后端服务将 code、appKey 和 sign(签名) 发送给 Mos 接口服务验证
- 验证成功返回当前登录用户的 session_key (会话信息) 和 openid (用户唯一标识)
用户登录成功后:
- 微应用后端服务可以将 openid 和 session_key 保存
- 生成自定义登录态的 token 返回给微应用
- 通过 token 可以查询 openid 和 session_key
- 微应用下次请求携带 token 即可证明已登录
提示
微应用后端服务可以在获取到 openid 后与绑定到微应用的用户体系,完成微应用用户和 Mos 用户体系的关联。
前端实现
登录示例代码
javascript
// 调用 mos.login 获取登录凭证 code
mos.login("your_app_key").then((res) => {
// 微应用后端服务登录
axios.post("https://demo-test.miniapp.xxx/api/login/miniAppLogin", {
code: res.data.code,
}).then((res1) => {
// Token保存到缓存,后续请求带上
localStorage.setItem("token", res1.data);
});
});
后端实现
java
package testproject.service.impl;
import cn.hutool.crypto.digest.MD5;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.TypeReference;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import testproject.domain.vo.TokenVo;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.*;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;
import java.util.Date;
import java.util.Map;
import java.util.TreeMap;
/**
* 微应用用户公共服务
*
* @author tom
*/
@Slf4j
@Component
public class MiniAppCommonService {
// 登录签名密钥
public static final String LOGIN_SIGNING_KEY = "your_complex_key_it_can_be_random";
/**
* Mos微应用的调用地址
*/
@Value("${mos.mini-app.base-url}")
private String baseUrl;
/**
* Mos微应用的appKey
*/
@Value("${mos.mini-app.app-key}")
private String appKey;
/**
* Mos微应用的appSecret
*/
@Value("${mos.mini-app.app-secret}")
private String appSecret;
@Autowired
private Environment env;
private final RestTemplate restTemplate;
public MiniAppCommonService() {
SimpleClientHttpRequestFactory httpRequestFactory = new SimpleClientHttpRequestFactory();
httpRequestFactory.setConnectTimeout(30000);
httpRequestFactory.setReadTimeout(60000);
this.restTemplate = new RestTemplate(httpRequestFactory);
}
/**
* 请求Mos服务端微应用开放接口
*
* @param uri 接口URI
* @param ao 请求参数
* @param clazz 响应类型
* @return 响应对象
*/
private <T> T call(String uri, BaseSignedAo ao, Class<T> clazz) {
// 签名
ao.setAppKey(this.appKey);
Map<String, String> data = JSON.parseObject(JSON.toJSONString(ao), new TypeReference<Map<String, String>>() {
});
String sign = MiniAppUtil.generateSignature(data, this.appSecret);
ao.setSign(sign);
// 发起请求
String url = this.baseUrl + uri;
ResponseEntity<String> responseEntity = restTemplate.postForEntity(url, ao, String.class);
if (responseEntity.getStatusCode() != HttpStatus.OK || responseEntity.getBody() == null) {
log.error("微应用请求异常 uri={} ao={}", uri, JSON.toJSONString(ao));
return null;
}
// 解析响应 code(int) message(String) data(T)
JSONObject result = JSON.parseObject(responseEntity.getBody());
if ((int)result.get("code") != 200) {
log.error("微应用请求异常 uri={} ao={} result={}", uri, JSON.toJSONString(ao), JSON.toJSONString(result));
return null;
}
return JSON.to(clazz, result.get("data"));
}
public MiniAppUserBo getMiniAppUser() {
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
String appKey = (String) request.getAttribute("appKey");
String openid = (String) request.getAttribute("openid");
String language = request.getHeader("Lang");
LanguageEnum languageType;
if (StringUtils.isBlank(language)) {
languageType = LanguageEnum.EN_US;
} else {
languageType = LanguageEnum.getByCode(language);
}
return new MiniAppUserBo(appKey, openid, languageType);
}
public TokenVo miniAppLogin(String code) {
MosSessionVo mosSessionVo = this.code2session(code);
if (mosSessionVo == null) {
return null;
}
String token = Jwts.builder()
.setSubject(this.appKey + "-" + mosSessionVo.getOpenid())
.setIssuedAt(new Date())
// 有效期改为360天
.setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 360))
.signWith(SignatureAlgorithm.HS256, LOGIN_SIGNING_KEY).compact();
return new TokenVo().setToken(token);
}
public MosSessionVo code2session(String code) {
Code2SessionAo ao = new Code2SessionAo().setCode(code);
return this.call("/open-apis/mp/v1/auth/code2session", ao, MosSessionVo.class);
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class MosSessionVo {
/**
* openid
*/
private String openid;
/**
* session key
*/
private String sessionKey;
}
@Data
public static class BaseSignedAo {
/**
* 微应用 appKey
*/
@NotNull
private String appKey;
/**
* 签名
*/
@NotNull
private String sign;
}
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = true)
public static class Code2SessionAo extends BaseSignedAo {
/**
* 通过 mos.login 获取的 code
*/
@NotNull
private String code;
}
@Getter
@AllArgsConstructor
public static enum LanguageEnum {
ZH_CN("zh-CN", "zh-CN"),
ZH_HK("zh-HK", "zh-HK"),
ZH_TW("zh-TW", "zh-TW"),
EN_US("en-US", "en-US"),
KM_KH("km-KH", "km-KH"),
VI_VN("vi-VN", "vi-VN"),
TH_TH("th-TH", "th-TH"),
MS_MY("ms-MY", "ms-MY"),
JA_JP("ja-JP", "ja-JP"),
KO_KR("ko-KR", "ko-KR"),
ID_ID("id-ID", "id-ID"),
LO_LA("lo-LA", "lo-LA"),
HI_IN("hi-IN", "hi-IN"),
;
@EnumValue
@JsonValue
private final String code;
private final String desc;
public static LanguageEnum getByCode(String code) {
if (code == null || code.isEmpty()) {
return null;
}
String[] paramSplits = code.split("-");
if (paramSplits.length > 1) {
for (LanguageEnum languageEnum : LanguageEnum.values()) {
if (languageEnum.getCode().equals(code)) {
return languageEnum;
}
}
}
// 兼容数据 之前传的是zh和en
else {
for (LanguageEnum languageEnum : LanguageEnum.values()) {
String[] enumSplits = languageEnum.getCode().split("-");
if (enumSplits[0].equalsIgnoreCase(code)) {
return languageEnum;
}
}
}
return null;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class MiniAppUserBo {
/**
* appKey
*/
private String appKey;
/**
* openid
*/
private String openid;
/**
* 语言类型 zh-CN中文 en-US英文
*/
private LanguageEnum languageType;
}
public static class MiniAppUtil {
/**
* 生成签名
*/
public static String generateSignature(Map<String, String> data, String appSecret) {
// 1. Sort the parameters alphabetically
Map<String, String> sortedData = new TreeMap<>(data);
// 2. Concatenate the parameters in the format key1=value1&key2=value2...
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedData.entrySet()) {
if ("sign".equals(entry.getKey()) || entry.getValue() == null || entry.getValue().isEmpty()) {
// sign 不参与签名 && 空值不参与签名
continue;
}
sb.append(entry.getKey()).append("=").append(entry.getValue().trim()).append("&");
}
sb.append("secret=").append(appSecret);
// 3. Hash the concatenated string using MD5
return new MD5().digestHex(sb.toString());
}
}
}
java
import testproject.domain.vo.LoginAo;
import testproject.domain.vo.TokenVo;
import testproject.service.impl.MiniAppCommonService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* The type Project member controller.
*/
@RestController
@RequestMapping("/mp/")
public class MiniAppController {
/**
* The Project member service.
*/
@Resource
private MiniAppCommonService miniAppCommonService;
/**
* 查询项目成员列表
*
* @param param the query str
* @return the table data info
*/
@PostMapping("/login/miniAppLogin")
public TokenVo miniAppLogin(@RequestBody @Validated LoginAo param) {
// 直接调用公共模块提到的通用登录方法获取 Token
TokenVo tokenVo = miniAppCommonService.miniAppLogin(param.getCode());
if(tokenVo == null) {
throw new RuntimeException("login failed, checking the parameter of code");
}
// check is the openid new in your db
// ... do it use tokenVo.getOpenId
return tokenVo.getToken();
}
}
java
package testproject.domain.vo;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* @author tom
*/
@Data
@Accessors(chain = true)
public class TokenVo {
/**
* token
*/
private String token;
/** the mosapp user relative id, don't share to frontend **/
private String openId;
}
java
package testproject.domain.vo;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 通过code换取登录token
*
* @author zhi
* @since 2024/7/16
*/
// 通过code换取mos用户信息入参
@Data
public class LoginAo {
@NotNull
// 通过mos.login获取的code
private String code;
}
java
// 最后,需要添加过滤器
package testproject.interceptor;
import testproject.service.impl.MiniAppCommonService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.Getter;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
/**
* @author 登录鉴权拦截器
*/
@Getter
@Configuration
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
return true;
}
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = Jwts.parser()
.setSigningKey(MiniAppCommonService.LOGIN_SIGNING_KEY)
.parseClaimsJws(token)
.getBody();
String[] subjects = claims.getSubject().split("-");
if (subjects.length == 2 && subjects[0] != null && subjects[1] != null && !this.isTokenExpired(token)) {
request.setAttribute("appKey", subjects[0]);
request.setAttribute("openid", subjects[1]);
return true;
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return false;
}
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return false;
}
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header");
return false;
}
}
private boolean isTokenExpired(String token) {
Claims claims = Jwts.parser()
.setSigningKey(MiniAppCommonService.LOGIN_SIGNING_KEY)
.parseClaimsJws(token)
.getBody();
return claims.getExpiration().before(new Date());
}
}
java
package testproject.interceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfigure implements WebMvcConfigurer {
private static final String[] EXCLUDE_PATH_PATTERNS_FOR_SWAGGER = {"/doc.html", "/v2/api-docs", "/swagger-resources/**", "/webjars/**"};
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册自定义拦截器
registry.addInterceptor(new LoginInterceptor())
// 拦截所有mp微应用的请求
.addPathPatterns("/mp/**")
// 排除swagger接口说明文档
.excludePathPatterns(EXCLUDE_PATH_PATTERNS_FOR_SWAGGER)
// 排除微应用登录路径
.excludePathPatterns("/mp/login/miniAppLogin");
}
}
Spring Security 配置
如果使用 Spring Security 进行安全认证,需要在配置中添加相应的权限: