统一认证

支持的认证方式有:

  • 账号和密码认证
  • 手机号加验证码认证
  • 微信扫码认证

认证的流程如下图:

要点解析:

AuthenticationManager 会委托 DaoAuthenticationProvider 进行认证,该类中的方法会校验从 loadUserByUsername() 返回的用户信息中的密码。我们项目中只有一种需要进行密码校验,所以我们需要自定义 DaoAuthenticationProvider,自己进行密码校验工作。

DaoAuthenticationProvider 会调用 UserDetailsService 的 loadUserByUsername方法,去获取 UserDetails 对象。我们的用户信息都存放在数据库,所以这里需要自定义 UserDetailsService

此外,因为username为单一对象,所以我们在传入username时,使用JSON数据,在后端使用JSON工具将其转为对象,从对象中获取登陆信息进行校验。

注意到第 6 步会返回一个 UserDetails,其中的username属性,会被放到JWT的数据部分,为了能让JWT携带更多的用户信息,在设置username属性时,可以设置为用户的JSON字符串

前端请求

使用密码获取Token时,前端请求链接如需下:

http://127.0.0.1:63010/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"t1", "password":"111111","authType":"password","checkcode": "4WHU","checkcodekey": "checkcode:f2f2a238515e4c1a937fce77ab46bde3"}

参数解释:其中的username即为JSON字符串,包含了登录所需信息

client_id:XcWebApp
client_secret:XcWebApp
grant_type:password
username:{"username":"t1", "password":"111111","authType":"password","checkcode": "4WHU","checkcodekey": "checkcode:f2f2a238515e4c1a937fce77ab46bde3"}

项目中有两个前端项目,他们都会从本地cookie中获取登录时保存的JWT信息,这时要注意两个项目的域名。门户网站的域名为: www.51xuecheng.cn,管理网站的域名为: teacher.51xuecheng.cn,如果想让两个网站共享cookie,在保存cookie时域要设置到二级域名:.51xuecheng.cn。就先下面这样:

实体类

接受登陆参数的AuthParamDTO

AuthParamDTO
@Data
public class AuthParamDTO {
/**
* 用户名
*/
private String username;

/**
* 密码
*/
private String password;

/**
* 手机号
*/
private String cellphone;

/**
* 验证码
*/
private String checkcode;

/**
* 验证码key
*/
private String checkcodekey;

/**
* 认证的类型 password 或 sms:短信模式
*/
private String authType;

/**
* 附加数据
*/
private Map<String, Object> payload = new HashMap<>();
}

保存到UserDetails中的信息

这里继承了XcUser,而不是直接使用。如果以后想添加别的内容可以直接在XcUserExt中添加。

@Data
@EqualsAndHashCode(callSuper = true)
public class XcUserExt extends XcUser {
}

自定义DaoAuthenticationProvider

我们项目中只有一种需要进行密码校验,所以我们需要自定义 DaoAuthenticationProvider,自己进行校验工作。

auth.config包下创建 DaoAuthenticationProviderCustom 类,继承 DaoAuthenticationProvider,重写其中的密码校验方法,置空即可。

注意UserDetailsService引入我们自己定义的

DaoAuthenticationProviderCustom
import com.swx.ucenter.service.UserDetailsService;
/**
* 重写DaoAuthenticationProvider校验密码方式
* 有些校验方式不需要密码
*/
@Component
public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {

@Autowired
public void setUserDetailsService(UserDetailsService userDetailsService) {
super.setUserDetailsService(userDetailsService);
}

@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

}
}

统一认证校验Service

针对不同方式的校验(账户+密码、手机+验证码、微信扫码),使用策略模式,定义统一认证校验Service接口,并给予不同的实现类。

校验接口定义

ucenter.service包下定义统一认证的接口

/**
* 统一认证接口
*/
public interface AuthService {

/**
* 认证方法
* @param dto 认证参数
* @return XcUserExt
*/
public XcUserExt execute(AuthParamDTO dto);
}
账户密码实现类

给该Bean的名称为 password_authService,其中password为认证方式,由前端传入,后面为固定字符串_authService,通过拼接方式可以拿到指定的BeanName

这里通过Feign远程调用了验证码微服务提供的校验方法,完成验证码校验。

CheckCodeClient
/**
* 远程调用校验验证码
*/
@FeignClient(value = "checkcode", fallbackFactory = CheckCodeClientFallbackFactory.class)
public interface CheckCodeClient {

@PostMapping("/checkcode/verify")
public Boolean verify(@RequestParam("key") String key, @RequestParam("code") String code);
}
CheckCodeClientFallbackFactory
@Slf4j
@Component
public class CheckCodeClientFallbackFactory implements FallbackFactory<CheckCodeClient> {
/**
* @param throwable
* @return
*/
@Override
public CheckCodeClient create(Throwable throwable) {
return (key, code) -> {
log.error("远程调用校验验证码服务失败,熔断降级, key: {}, code: {}", key, code);
return false;
};
}
}
PasswordAuthServiceImpl
/**
* 密码认证方式实现类
*/
@Slf4j
@Service("password_authService")
public class PasswordAuthServiceImpl implements AuthService {
private final XcUserService xcUserService;
private final PasswordEncoder passwordEncoder;
private final CheckCodeClient checkCodeClient;

public PasswordAuthServiceImpl(XcUserService xcUserService, PasswordEncoder passwordEncoder, CheckCodeClient checkCodeClient) {
this.xcUserService = xcUserService;
this.passwordEncoder = passwordEncoder;
this.checkCodeClient = checkCodeClient;
}

/**
* 认证方法
*
* @param dto 认证参数
* @return XcUserExt
*/
@Override
public XcUserExt execute(AuthParamDTO dto) {
String username = dto.getUsername();

// 校验验证码
String key = dto.getCheckcodekey();
String code = dto.getCheckcode();
if (StringUtils.isEmpty(key) || StringUtils.isEmpty(code)) {
log.debug("认证失败: 验证码参数不完整");
throw new BadCredentialsException("验证码参数不完整");
}

// 远程调用校验验证码服务,检验
Boolean verify = checkCodeClient.verify(key, code);
if (!verify) {
log.debug("认证失败: 验证码不合法");
throw new BadCredentialsException("验证码不合法");
}
// 查询数据库
XcUser dbXcUser = Optional.ofNullable(xcUserService.getOne(Wrappers.<XcUser>lambdaQuery().eq(XcUser::getUsername, username)))
.orElseThrow(() -> new UsernameNotFoundException("账号不存在"));
//dbXcUser.getStatus()

// 检验密码
String password = dbXcUser.getPassword();
if (!passwordEncoder.matches(dto.getPassword(), password)) {
log.debug("认证失败: 密码错误");
throw new BadCredentialsException("密码错误");
}

XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(dbXcUser, xcUserExt);
return xcUserExt;
}
}
微信登录实现类
/**
* 微信扫码认证方式
*/
@Slf4j
@RefreshScope
@Service("wx_authService")
public class WxAuthServiceImpl implements AuthService {

private final XcUserService xcUserService;

public WxAuthServiceImpl(XcUserService xcUserService) {
this.xcUserService = xcUserService;
}

/**
* 认证方法
*
* @param dto 认证参数
* @return XcUserExt
*/
@Override
public XcUserExt execute(AuthParamDTO dto) {
String username = dto.getUsername();
if (StringUtils.isEmpty(username)) {
throw new BizException(ResultCodeEnum.PARAM_INVALID);
}
// 查询数据库
XcUser xcUser = xcUserService.getOne(Wrappers.<XcUser>lambdaQuery().eq(XcUser::getUsername, username));
if (xcUser == null) {
throw new BizException(ResultCodeEnum.DATA_NOT_EXIST);
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(xcUser, xcUserExt);
return xcUserExt;
}
}

自定义UserDetailsService

定义 UserDetailsService,继承Spring Security的 UserDetailsService,实现 loadUserByUsername 方法

UserDetailsService
public interface UserDetailsService extends org.springframework.security.core.userdetails.UserDetailsService {

/**
* 根据账号获取用户对象,获取不到直接抛异常
*
* @param account 账号
* @return 用户完整信息
*/
@Override
UserDetails loadUserByUsername(String account) throws UsernameNotFoundException;
}

impl包下实现该接口,传入的account参数,是从前端来的JSON字符对象,使用JSON工具将其转为 AuthParamDTO 对象:

  1. 以认证方式(AuthType)拼接beanName的方式获取指定的检验实现类,通过ApplicationContext获取到实现类对象,调用其中的 execute 方法完成校验。
  2. 将返回的用户信息转为JSON字符串,将其放到 UserDetails 中的 username 中,方便放到JWT的数据部分。
/**
* 实现自定义UserDetailsService
*/
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

private final ApplicationContext applicationContext;

public UserDetailsServiceImpl(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}

/**
* 根据账号获取用户对象,获取不到直接抛异常
*
* @param account 账号
* @return 用户完整信息
*/
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
AuthParamDTO authParamDTO = null;
try {
authParamDTO = JSON.parseObject(account, AuthParamDTO.class);
} catch (Exception e) {
throw new RuntimeException("认证请求参数不符合要求");
}

String authType = authParamDTO.getAuthType();
if (StringUtils.isEmpty(authType)) {
throw new RuntimeException("未指定认证方式");
}
String beanName = authType + "_authService";

// 根据认证类型从spring容器中取出指定Bean
AuthService authService = applicationContext.getBean(beanName, AuthService.class);
// 调用统一execute方法完成认证
XcUserExt xcUserExt = authService.execute(authParamDTO);

return getUserPrincipal(xcUserExt);
}

/**
* 查询用户信息
* @param xcUserExt 用户信息
* @return UserDetails
*/
private UserDetails getUserPrincipal(XcUserExt xcUserExt) {
String password = xcUserExt.getPassword();
// 权限
String[] authorities = {"test"};
// 转JSON
xcUserExt.setPassword(null);
String userJson = JSON.toJSONString(xcUserExt);

// 封装 UserDetails 对象
return User.withUsername(userJson)
.password(password)
.authorities(authorities).build();
}
}

接口测试

获取Token

用户名+密码方式获取Token

http://127.0.0.1:63010/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"t1", "password":"111111","authType":"password"}

参数:

{
"client_id": "XcWebApp",
"client_secret": "XcWebApp",
"grant_type": "password",
"username": "{\"username\":\"t1\", \"password\":\"111111\",\"authType\":\"password\"}"
}
  • client_id: 客户端密钥
  • client_secret: 客户端密匙
  • grant_type: 认证方式
  • username: 登陆参数

集成微信扫码登录

网站应用微信登录开发获取access_token流程如下:

  1. 第三方发起微信授权登录请求,微信用户允许授权第三方应用后,微信会拉起应用或重定向到第三方网站,并且带上授权临时票据code参数;
  2. 通过code参数加上AppID和AppSecret等,通过API换取access_token;
  3. 通过access_token进行接口调用,获取用户基本数据资源或帮助用户实现基本操作。

请求code的参数如下:

self_redirect:true,
id:"login_container",
appid: "wxed9954c01bb89b47",
scope: "snsapi_login",
redirect_uri: "http://localhost:8160/api/auth/wxLogin",
state: token,
style: "",
href: ""
微信扫码回调接口

auth.controller包下创建 WxLoginController

WxLoginController
@Slf4j
@Controller
public class WxLoginController {

private final String REDIRECT_URL = "redirect:http://www.51xuecheng.cn/sign.html?username=%s&authType=wx";
private final WxAuthService wxAuthService;

public WxLoginController(WxAuthService wxAuthService) {
this.wxAuthService = wxAuthService;
}

@RequestMapping("/wxLogin")
public String wxLogin(String code, String state) {
log.debug("微信扫码回调, code: {}, state: {}", code, state);
if (StringUtils.isEmpty(code)) {
throw new RuntimeException("无code参数");
}
XcUser xcUser = wxAuthService.wxAuth(code);
String username = xcUser.getUsername();
return String.format(REDIRECT_URL, username);
}

}
请求令牌方法

ucenter.service下定义该方法的接口 WxAuthService,具体流程如下:

  • 使用 code、appid、secret等参数去请求 access_token
  • 使用 access_token 获取微信用户的基本信息
  • 根据基本信息中的 unionid(用户在授权网站的唯一ID),判断微信用户是否注册,如果注册返回用户信息;否则将用户数据保存到项目数据库。
WxAuthService
/**
* 微信扫码接口
*/
public interface WxAuthService {

/**
* 微信扫码认证,携带令牌查询用户信息、保存到自己数据库
*
* @param code code
* @return com.swx.ucenter.model.po.XcUser 用户信息
*/
public XcUser wxAuth(String code);

}

实现放在 WxAuthServiceImpl 类中,添加实现父类 WxAuthService

WxAuthServiceImpl
/**
* 微信扫码认证方式
*/
@Slf4j
@RefreshScope
@Service("wx_authService")
public class WxAuthServiceImpl implements AuthService, WxAuthService {

@Value("${wechat.appid}")
private String appid;
@Value("${wechat.secret}")
private String secret;
private final RestTemplate restTemplate;
private final XcUserService xcUserService;
private final XcUserRoleService xcUserRoleService;
private final TransactionTemplate transactionTemplate;

public WxAuthServiceImpl(RestTemplate restTemplate, XcUserService xcUserService,
XcUserRoleService xcUserRoleService, TransactionTemplate transactionTemplate) {
this.restTemplate = restTemplate;
this.xcUserService = xcUserService;
this.xcUserRoleService = xcUserRoleService;
this.transactionTemplate = transactionTemplate;
}

/**
* 认证方法
*
* @param dto 认证参数
* @return XcUserExt
*/
@Override
public XcUserExt execute(AuthParamDTO dto) {
String username = dto.getUsername();
if (StringUtils.isEmpty(username)) {
throw new BizException(ResultCodeEnum.PARAM_INVALID);
}
// 查询数据库
XcUser xcUser = xcUserService.getOne(Wrappers.<XcUser>lambdaQuery().eq(XcUser::getUsername, username));
if (xcUser == null) {
throw new BizException(ResultCodeEnum.DATA_NOT_EXIST);
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(xcUser, xcUserExt);
return xcUserExt;
}

/**
* 微信扫码认证,申请令牌, 携带令牌查询用户信息、保存到自己数据库
*
* @param code code
* @return com.swx.ucenter.model.po.XcUser 用户信息
*/
@Override
public XcUser wxAuth(String code) {
// 申请令牌
Map<String, String> accessTokenMap = getAccessToken(code);
String accessToken = accessTokenMap.get("access_token");
String openid = accessTokenMap.get("openid");
// 携带令牌查询用户信息
Map<String, String> userInfo = getUserInfo(accessToken, openid);
// 保存到自己数据库
return transactionTemplate.execute((status) -> {
try {
return addWxUser(userInfo);
} catch (Exception e) {
log.error("新增用户失败", e);
status.setRollbackOnly();
}
return null;
});
}

public XcUser addWxUser(Map<String, String> userInfoMap) {
// unionid,用户在网站上的唯一ID。
String unionid = userInfoMap.get("unionid");
String nickname = userInfoMap.get("nickname");
XcUser xcUser = xcUserService.getOne(Wrappers.<XcUser>lambdaQuery().eq(StringUtils.hasText(unionid), XcUser::getWxUnionid, unionid));
// 已注册,直接返回
if (xcUser != null) {
return xcUser;
}

// 新增用户
xcUser = new XcUser();
xcUser.setId(UUID.randomUUID().toString());
xcUser.setWxUnionid(unionid);
xcUser.setPassword(unionid);
xcUser.setUsername(unionid);
xcUser.setNickname(nickname);
xcUser.setName(nickname);
xcUser.setCreateTime(LocalDateTime.now());
xcUser.setUtype("101001");
xcUser.setStatus("1");
xcUser.setSex(userInfoMap.get("sex"));
xcUserService.save(xcUser);

// 新增用户角色信息
XcUserRole xcUserRole = new XcUserRole();
xcUserRole.setUserId(xcUser.getId());
xcUserRole.setId(UUID.randomUUID().toString());
xcUserRole.setRoleId("17"); // 17 表示学生角色
xcUserRole.setCreateTime(LocalDateTime.now());
xcUserRoleService.save(xcUserRole);

return xcUser;
}

/**
* 通过code获取access_token
* https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
* {
* "access_token":"ACCESS_TOKEN",
* "expires_in":7200,
* "refresh_token":"REFRESH_TOKEN",
* "openid":"OPENID",
* "scope":"SCOPE",
* "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
* }
*
* @param code code
*/
private Map<String, String> getAccessToken(String code) {
// 填充url
String url = String.format(WxAuthURLConstants.ACCESS_TOKEN, appid, secret, code);
// 发起请求
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
String result = exchange.getBody();
// 解析body
return handleResult(result);
}

/**
* 获取用户个人信息
* https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID
* {
* "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"
* }
*
* @param accessToken 令牌
* @param openId 用户的标识
*/
private Map<String, String> getUserInfo(String accessToken, String openId) {
// 填充url
String url = String.format(WxAuthURLConstants.USER_INFO, accessToken, openId);
// 发起请求
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
String result = exchange.getBody();
// 解析body
return handleResult(result);
}

/**
* 解析body
*
* @param body body
*/
private Map<String, String> handleResult(String body) {
if (StringUtils.isEmpty(body)) {
log.error("请求access_token出错, body为空");
throw new BizException("请求access_token出错");
}
String body_utf8 = new String(body.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
// 将result转为Map
Map<String, String> map = JSON.parseObject(body_utf8, new TypeReference<HashMap<String, String>>() {});
// 错误判断
if (StringUtils.hasText(map.get("errcode"))) {
String errmsg = map.get("errmsg");
String errcode = map.get("errcode");
log.error("请求access_token出错, errcode: {}, errmsg: {}", errcode, errmsg);
throw new BizException(errmsg);
}
return map;
}
}