将前面写的用户名密码登录、短信登录、第三方账户登录整合成OAuth2协议生成token的模式
AuthenticationSuccessHandler
调用AuthorizationServerTokenServices
返回令牌,AuthenticationSuccessHandler
中含有Authentication
信息,缺少OAuth2Request
信息,组装OAuth2Request
信息需要ClientDetails
以及TokenRequest
信息
用户名密码登录
-
从请求头中获取clientId
package com.cong.security.app.authentication; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException; import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.ClientDetailsService; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.OAuth2Request; import org.springframework.security.oauth2.provider.TokenRequest; import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.www.BasicAuthenticationConverter; import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; /** * 成功处理函数 */ @Slf4j @Component("myAuthenticationSuccessHandler") public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { /** * 工具类,将authentication转换成为json */ @Autowired private ObjectMapper objectMapper; // 读取信息 @Autowired private ClientDetailsService clientDetailsService; @Autowired private AuthorizationServerTokenServices authorizationServerTokenServices; private BasicAuthenticationConverter authenticationConverter = new BasicAuthenticationConverter(); /** * Authentication封装认证信息 */ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 验证clientId及secret,SpringSecurity升级之后将方法抽取出来了,所以就不需要拷贝代码了 UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request); if (authRequest == null) { log.info("从请求头中未获取到client信息"); throw new UnapprovedClientAuthenticationException("请求头参数不合法,不予处理"); } String clientId = authRequest.getName(); // 从请求中获取ClientId,利用ClientDetailsService接口获取到ClientDetails对象 ClientDetails client = clientDetailsService.loadClientByClientId(clientId); // 简单的校验 if (client == null) { log.info("clientId:[{}]不存在", clientId); throw new UnapprovedClientAuthenticationException("clientId " + clientId + " 不存在"); } else if (!StringUtils.equals(authRequest.getCredentials().toString(), client.getClientSecret())) { // 判断密码是否匹配 log.info("用户输入的clientId:[{}]对应的clientSecret:[{}]与系统存储的secret:[{}]不匹配", clientId, client.getClientSecret(), authRequest.getCredentials().toString()); throw new UnapprovedClientAuthenticationException("clientSecret不匹配"); } // ClientDetails信息无误,开始new TokenRequest // authentication已经有信息,不需要重复获取 TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, client.getScope(), "custom"); // 创建OAuth2Request OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(client); // 拼OAuth2Authentication OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication); // 生成access_token OAuth2AccessToken oAuth2AccessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication); // 配置返回json response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(oAuth2AccessToken)); } }
SpringSecurity
升级之后将从请求头中获取client
信息的方法进行了一次封装,直接调用AuthenticationConverter
接口的convert
方法即可(使用BasicAuthenticationConverter
实现类,如果想更改加密方式或者验证逻辑可以覆写)
配置资源服务器安全配置:package com.cong.security.app.authentication; import com.cong.security.core.code.SmsCodeFilter; import com.cong.security.core.code.sms.SmsCodeAuthenticationSecurityConfig; import com.cong.security.core.code.sms.SmsCodeSender; import com.cong.security.core.properties.SecurityProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.social.security.SpringSocialConfigurer; import org.springframework.web.cors.CorsUtils; @Configuration @EnableResourceServer public class MyResourceServerConfig<AuthorizeConfigManager> extends ResourceServerConfigurerAdapter { // 安全配置 @Autowired private SecurityProperties securityProperties; // 成功处理器 @Autowired private AuthenticationSuccessHandler myAuthenticationSuccessHandler; // 失败处理器 @Autowired private AuthenticationFailureHandler myAuthenticationFailureHandler; // 短信登录 @Autowired private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig; // 三方登录 @Autowired private SpringSocialConfigurer mySocialSecurityConfig; // 短信发送接口 @Autowired private SmsCodeSender smsCodeSender; // 三方账户绑定 @Autowired private UsersConnectionRepository usersConnectionRepository; @Override public void configure(HttpSecurity http) throws Exception { SmsCodeFilter smsCodeFilter = new SmsCodeFilter(); smsCodeFilter.setMyAuthenticationFailureHandler(myAuthenticationFailureHandler); smsCodeFilter.setSecurityProperties(securityProperties); smsCodeFilter.setSmsCodeSender(smsCodeSender); smsCodeFilter.setUsersConnectionRepository(usersConnectionRepository); // 初始化方法 smsCodeFilter.afterPropertiesSet(); http.authorizeRequests().antMatchers(HttpMethod.OPTIONS).permitAll();// 放行预请求 http.formLogin() .loginProcessingUrl("/app/login")// 系统登陆请求路径为/app/login,此处设置目的是使用UsernamePasswordAuthenticationFilter处理此处登录请求 .successHandler(myAuthenticationSuccessHandler)// 自定义成功处理器 .failureHandler(myAuthenticationFailureHandler);// 自定义失败处理器 http.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class)// 在用户名密码校验之前添加验证码校验 .apply(mySocialSecurityConfig)// 三方登录 .and().apply(smsCodeAuthenticationSecurityConfig)// 短信验证码 .and().authorizeRequests().requestMatchers(CorsUtils::isPreFlightRequest).permitAll()// 解决浏览器端预请求直接放过,不作处理 .and().csrf().disable();// 跨站请求访问 } }
使用postman测试接口
使用返回的access_token即可获得访问资源服务器接口。
短信登录
- 前面开发的时候短信验证码图形验证码都保存在redis中,实际项目中一般也是使用redis(无论APP还是PC端,我在开发的时候尽量抛弃session),即使修改也是修改接口实现类(保存和校验使用同一套)
- 图形验证码我在APP模式下基本不使用(目前只在发送短信的接口可能使用,防止别人盗刷,在接口中添加客户端设备标识
deviceId
稍微修改一下代码逻辑即可) - 短信验证码发送接口就需要设备标识了(目的是实现第三方账户绑定,逻辑在社交登录中修改)
使用postman调用短信验证码发送接口获取验证码,调用SmsCodeFilter
配置的短信验证码登录接口即可实现短信验证码登录。
社交登录
三方账户登录
服务提供商提供的授权码模式有两种
-
简化模式
第三方直接返回openId,系统可以直接根据openId进行登录,不需要拿access_token去换取openId(我本来做的APP端走的授权码模式,但是对接的前端使用QQ登录和微信登录的时候直接获取到openId)
代码逻辑同短信验证码校验定义
OpenIdAuthenticationToken
封装登录信息:package com.cong.security.core.social.app; import java.util.Collection; import lombok.Data; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import lombok.extern.slf4j.Slf4j; @Slf4j @Data public class OpenIdAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 1L; // 用户openId private Object principal; //用户登录方式(本系统中为qq/weixin) private String providerId; //设备标识(未绑定情况) private String clientId; /** * 构造函数 * * @param openId 用户openId * @param clientId 客户端设备编号 * @param providerId 用户登录方式 */ public OpenIdAuthenticationToken(String openId, String clientId, String providerId) { super(null); // 用户openId this.principal = openId; this.clientId = clientId; log.info("当前用户[{}]在设备[{}]上采用[{}]模式登录系统", openId, clientId, providerId); // 是哪一个服务提供商的 this.providerId = providerId; setAuthenticated(false); } public OpenIdAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); } public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } super.setAuthenticated(false); } @Override public Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.principal; } }
OpenIdAuthenticationFilter
过滤器拦截请求封装OpenIdAuthenticationToken
package com.cong.security.core.social.app; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import com.cong.security.core.constant.SecurityConstant; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import lombok.extern.slf4j.Slf4j; @Slf4j public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter { //默认openId参数名 private String openIdParameter = SecurityConstant.DEFAULT_PARAMETER_NAME_OPENID; //默认clientId参数名(设备编号,解决用户未绑定手机号时根据设备编号在缓存中临时存储第三方信息) private String deviceIdParameter = SecurityConstant.DEFAULT_PARAMETER_NAME_DEVICEID; //默认登录方式参数名 private String providerIdParameter = SecurityConstant.DEFAULT_PARAMETER_NAME_PROVIDERID; private boolean postOnly = true; protected OpenIdAuthenticationFilter() { super(new AntPathRequestMatcher(SecurityConstant.DEFAULT_LOGIN_PROCESSING_URL_OPENID, "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String openId = obtainOpenId(request); String deviceId = obtainDeviceId(request); String providerId = obtainProviderId(request); log.info("封装三方[{}]用户[{}]来源于[{}]的信息", providerId, openId, deviceId); OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(openId, deviceId, providerId); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } /** * 获取clientId */ protected String obtainDeviceId(HttpServletRequest request) { String deviceId = request.getParameter(deviceIdParameter); if (deviceId == null) { deviceId = ""; } return deviceId.trim(); } /** * 获取openId */ protected String obtainOpenId(HttpServletRequest request) { String openId = request.getParameter(openIdParameter); if (openId == null) { openId = ""; } return openId.trim(); } /** * 获取providerId */ protected String obtainProviderId(HttpServletRequest request) { String providerId = request.getParameter(providerIdParameter); if (providerId == null) { providerId = ""; } return providerId.trim(); } protected void setDetails(HttpServletRequest request, OpenIdAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } }
OpenIdAuthenticationProvider
验证OpenIdAuthenticationToken
package com.cong.security.core.social.app; import java.util.HashSet; import java.util.Set; import org.apache.commons.collections.CollectionUtils; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.social.security.SocialUserDetailsService; import lombok.extern.slf4j.Slf4j; @Slf4j public class OpenIdAuthenticationProvider implements AuthenticationProvider { private SocialUserDetailsService userDetailsService; // UserConnection表 private UsersConnectionRepository usersConnectionRepository; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OpenIdAuthenticationToken openIdAuthenticationToken = (OpenIdAuthenticationToken) authentication; Set<String> providerUserIds = new HashSet<>(); // 获取到用户登陆的openId providerUserIds.add((String) authentication.getPrincipal()); // 用户选择的登录方式 String providerId = openIdAuthenticationToken.getProviderId(); log.info("用户[{}]登录方式为[{}]", providerUserIds, providerId); // 数据库中是否有记录 Set<String> userIds = usersConnectionRepository.findUserIdsConnectedTo(providerId, providerUserIds); log.info("匹配到的用户信息ID为[{}]", userIds);// 理论上只能拿到一个,一个系统账号可以绑定多个社交账号,但是一个社交账号只能绑定一个系统账号 if (CollectionUtils.isEmpty(userIds) || userIds.size() != 1) { throw new InternalAuthenticationServiceException("该账户尚未绑定至系统账号,提示用户执行绑定操作"); } // 根据社交账号查出来的用户的唯一标识 String userId = userIds.iterator().next(); UserDetails user = userDetailsService.loadUserByUserId(userId); if (user == null) { // 绑定的用户标识无法从本系统中查询到用户信息,主动抛出异常 throw new InternalAuthenticationServiceException("无法获取用户信息"); } // 构建用户以及用户权限信息 OpenIdAuthenticationToken openIdAuthenticationResult = new OpenIdAuthenticationToken(user, user.getAuthorities()); // UserDetails或者SocialDetails用户信息 openIdAuthenticationResult.setDetails(openIdAuthenticationToken.getDetails()); // 返回封装的token信息,上层进行JWT-token生成 return openIdAuthenticationResult; } @Override public boolean supports(Class<?> authentication) { return OpenIdAuthenticationToken.class.isAssignableFrom(authentication); } public void setUserDetailsService(SocialUserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } public void setUsersConnectionRepository(UsersConnectionRepository usersConnectionRepository) { this.usersConnectionRepository = usersConnectionRepository; } }
OpenIdAuthenticationSecurityConfig
配置类package com.cong.security.core.social.app; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.social.connect.UsersConnectionRepository; import org.springframework.social.security.SocialUserDetailsService; import org.springframework.stereotype.Component; /** * 当前配置为APP端登录,和浏览器端没有任何关系,可以单独修改,不会共用当前配置 */ @Component public class OpenIdAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private AuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private AuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private SocialUserDetailsService userDetailsService; @Autowired private UsersConnectionRepository usersConnectionRepository; @Override public void configure(HttpSecurity http) throws Exception { OpenIdAuthenticationFilter openIdAuthenticationFilter = new OpenIdAuthenticationFilter(); openIdAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // 成功失败处理器 openIdAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); openIdAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); OpenIdAuthenticationProvider openIdAuthenticationProvider = new OpenIdAuthenticationProvider(); openIdAuthenticationProvider.setUserDetailsService(userDetailsService); openIdAuthenticationProvider.setUsersConnectionRepository(usersConnectionRepository); http.authenticationProvider(openIdAuthenticationProvider).addFilterAfter(openIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
上述配置相关常量:
在资源服务器上添加OpenId配置类
postman测试
-
授权码模式
APP模式使用简化模式,授权码模式PC端使用即可,绝大多数用户执行第三方绑定之后一般都使用三方账户登录,使用授权码模式反而麻烦,直接使openId登录更简单。
三方账户注册
- 用户未绑定情况下(本文不使用默认注册逻辑,目前的互联网项目开发基本都需要用户实名制,隐式注册基本不会使用,纯粹刷用户量不算,而且隐式注册之后后面的账号整合也是问题)
- 视频中提供的账户注册逻辑此处不使用
三方账户注册需要和手机号进行绑定,需要发送短信验证码,逻辑写在短信验证码登录的过滤器中,后面文章编写。