统一认证
支持的认证方式有:
认证的流程如下图:
要点解析:
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;
private String checkcodekey;
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引入我们自己定义的
DaoAuthenticationProviderCustomimport com.swx.ucenter.service.UserDetailsService;
@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 {
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> {
@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; }
@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("账号不存在"));
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; }
@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 方法
UserDetailsServicepublic interface UserDetailsService extends org.springframework.security.core.userdetails.UserDetailsService {
@Override UserDetails loadUserByUsername(String account) throws UsernameNotFoundException; }
|
在impl
包下实现该接口,传入的account
参数,是从前端来的JSON字符对象,使用JSON工具将其转为 AuthParamDTO 对象:
- 以认证方式(AuthType)拼接beanName的方式获取指定的检验实现类,通过ApplicationContext获取到实现类对象,调用其中的 execute 方法完成校验。
- 将返回的用户信息转为JSON字符串,将其放到 UserDetails 中的 username 中,方便放到JWT的数据部分。
@Slf4j @Service public class UserDetailsServiceImpl implements UserDetailsService {
private final ApplicationContext applicationContext;
public UserDetailsServiceImpl(ApplicationContext applicationContext) { this.applicationContext = applicationContext; }
@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";
AuthService authService = applicationContext.getBean(beanName, AuthService.class); XcUserExt xcUserExt = authService.execute(authParamDTO);
return getUserPrincipal(xcUserExt); }
private UserDetails getUserPrincipal(XcUserExt xcUserExt) { String password = xcUserExt.getPassword(); String[] authorities = {"test"}; xcUserExt.setPassword(null); String userJson = JSON.toJSONString(xcUserExt);
return User.withUsername(userJson) .password(password) .authorities(authorities).build(); } }
|
接口测试
获取Token
用户名+密码方式获取Token
参数:
{ "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流程如下:
- 第三方发起微信授权登录请求,微信用户允许授权第三方应用后,微信会拉起应用或重定向到第三方网站,并且带上授权临时票据code参数;
- 通过code参数加上AppID和AppSecret等,通过API换取access_token;
- 通过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 {
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; }
@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; }
@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) { 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"); xcUserRole.setCreateTime(LocalDateTime.now()); xcUserRoleService.save(xcUserRole);
return xcUser; }
private Map<String, String> getAccessToken(String code) { 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(); return handleResult(result); }
private Map<String, String> getUserInfo(String accessToken, String openId) { String url = String.format(WxAuthURLConstants.USER_INFO, accessToken, openId); ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.GET, null, String.class); String result = exchange.getBody(); return handleResult(result); }
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); 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; } }
|