类似的介绍有不少,这里总结一下,作为备忘,也从不同的角度进行描述一下,以共用得着的同学参考。
框架原有登录、token生成过程
- client(app,网页等)发出post 请求,到auth server 的/oauth/token 端点,对应org.springframework.security.oauth2.provider.endpoint.TokenEndpoint类中的postAccessToken()方法;
- 被ClientCredentialTokenEndpointFilter拦截处理,封装请求中的登录信息为AuthenticationToken,调用attemptAuthentication() 进行验证;
- AuthenticationManager接管认证操作,查找匹配的AuthenticationProvider,调用其authenticate() 方法进行具体的认证处理: 如果通过,返回进一步丰富内容的AuthenticationToken,否则抛出异常;
- TokenEndpoint.postAccessToken() 如果拿到通过验证的AuthenticationToken,再进一步验证(scope,grantz_type)会生成TokenRequest,然后调用TokenGranter进行OAuth2AccessToken的生成,最后返回给调用的client。
扩展方案
当然可以扩展TokenEndpoint,以处理登录请求中不同的登录信息。但这种方式有些侵入性,不如下面这种方式内聚性好。那就是:
增加一套如下的类:
- AuthenticationFilter:用于拦截扩展的登录请求地址(例如:/oauth/extoken),并初步封装请求中的登录信息,例如电话号码,或者其他社交媒体的userid,或者其他应用系统的token(当然要有对应的验证、解析方法);
- AuthenticationToken:用于封装上述Filter中提取到的登录信息;
- AuthenticationProvider:用于验证上述自定义的AuthenticationToken中封装的信息,并进一步填充后续token获取所需要的信息;
- LoginSuccessHandler:用于替代TokenEndpoint中,进行token生成的操作(因为框架原有的TokenEndpoint.postAccessToken()不再执行);
- AuthenticationConfigurer:用于将上述功能配置到系统中。
代码片段
本文并未给出所用到的clientDetailService,和userDetailService,相信读者应该已经有了对应的实现,或者简单做一个inMemory的实现也很简单。
- Filter
package stoney.exer.spring.authsvr.exauth; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationEventPublisher; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 一个通用的外部授权Filter。生成Bean时,可以指定路径,和请求参数 key */ @Slf4j public class Ex1AuthenticationFilter extends AbstractAuthenticationProcessingFilter { @Setter@Getter private AuthenticationEventPublisher eventPublisher; private String exTokenKey ; private boolean postOnly = true; /** * * @param authPath 请求登录的路径 * @param exTokenKey 约定的Header中的token key */ public Ex1AuthenticationFilter(String authPath,String exTokenKey) { super(new AntPathRequestMatcher(authPath, "POST")); this.exTokenKey = exTokenKey; } @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 extoken = obtainToken(request); if (extoken == null) { extoken = ""; } extoken = extoken.trim(); AbstractAuthenticationToken authRequest = new Ex1AuthenticationToken(extoken,extoken , null); // Allow subclasses to set the "details" property setDetails(request, authRequest); Authentication authResult = null; try { authResult = this.getAuthenticationManager().authenticate(authRequest); log.debug("Authentication success: " + authResult); SecurityContextHolder.getContext().setAuthentication(authResult); } catch (Exception failed) { SecurityContextHolder.clearContext(); log.debug("Authentication request failed: " + failed); } return authResult; } protected String obtainToken(HttpServletRequest request) { String token = request.getParameter(exTokenKey); if (token == null ) { token = request.getHeader(exTokenKey); } return token; } protected void setDetails(HttpServletRequest request, AbstractAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } }
- Token
package stoney.exer.spring.authsvr.exauth; import lombok.Getter; import lombok.Setter; import lombok.SneakyThrows; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import java.util.Collection; //若标记为@Transient暂态,HttpSessionSecurityContextRepository 将不会存储这个Authentication; // 若在sessionCreationPolicy 为 STATELESS 时,也可以不标记 //@Transient @Getter @Setter public class Ex1AuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = -2826984876386002624L; protected Object principal; protected String exToken; public Ex1AuthenticationToken(Collection<? extends GrantedAuthority> authorities) { super(authorities); super.setAuthenticated(false); } public Ex1AuthenticationToken(Object principal, String exToken, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.exToken = exToken; super.setAuthenticated(true); } @Override @SneakyThrows public void setAuthenticated(boolean isAuthenticated) { 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 principal; } @Override public void eraseCredentials() { super.eraseCredentials(); } }
- Provider
package stoney.exer.spring.authsvr.exauth; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.SpringSecurityMessageSource; import org.springframework.security.core.userdetails.UserDetails; import stoney.exer.spring.authsvr.service.MemoryUserService; @Slf4j @Getter @Setter public class Ex1AuthenticationProvider implements AuthenticationProvider { private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); @Autowired private MemoryUserService userDetailsService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // TODO: 如果是针对外部token,此处要做token解析以得到对应的本系统用户信息;或者是电话号码,此处要做短信码验证;或者是微信openid,此处要做绑定关系查询 Ex1AuthenticationToken exAuthToken = (Ex1AuthenticationToken)authentication; String exToken = exAuthToken.getExToken(); // 假设解析出来的uid已经设置为用户信息 String principal = authentication.getPrincipal().toString(); //TODO: 假设解析出来的用户就是user+token。此处一定要改为自己的用户获取逻辑!!! principal = "user"+exToken; UserDetails userDetails = userDetailsService.loadUserByUsername(principal); if (userDetails == null) { log.debug("Authentication failed: no credentials provided"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.noopBindAccount", "没有绑定账号:"+principal)); } // 检查账号状态:锁定,禁用等等 // detailsChecker.check(userDetails); // 填充userDetails 和 Authorities Ex1AuthenticationToken authenticationToken = new Ex1AuthenticationToken(userDetails.getUsername(), exToken, userDetails.getAuthorities()); // 如果对应本系统正常用户,可以在此提取权限信息 authenticationToken.setDetails(authenticationToken.getDetails()); return authenticationToken; } @Override public boolean supports(Class<?> aClass) { return Ex1AuthenticationToken.class.isAssignableFrom(aClass); } }
- Handler
package stoney.exer.spring.authsvr.exauth; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Builder; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException; import org.springframework.security.oauth2.provider.*; import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestValidator; import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import stoney.exer.common.auth.AuthUtils; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.io.PrintWriter; import java.util.HashMap; @Builder //@Component @Slf4j public class Ex1LoginSuccessHandler implements AuthenticationSuccessHandler { private static final String BASIC_ = "Basic "; private ObjectMapper objectMapper; private PasswordEncoder passwordEncoder; private ClientDetailsService clientDetailsService; private AuthorizationServerTokenServices defaultAuthorizationServerTokenServices; /** * Called when a user has been successfully authenticated. * 调用spring security oauth API 生成 oAuth2AccessToken * 如果不进行 生成 oAuth2AccessToken这一步,认证虽然可以成功(在有Session的情况下,会保存SessionID), * 但无法返回token * * @param request the request which caused the successful authentication * @param response the response * @param authentication the <tt>Authentication</tt> object which was created during */ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { log.debug("Authentiation Success event!"); String header = request.getHeader(HttpHeaders.AUTHORIZATION); // if(true) return; if (header == null || !header.startsWith(BASIC_)) { throw new UnapprovedClientAuthenticationException("请求头中client信息为空"); } try { String[] tokens = AuthUtils.extractAndDecodeHeader(header); assert tokens.length == 2; String clientId = tokens[0]; ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId); TokenRequest tokenRequest = new TokenRequest(new HashMap<>(), clientId, clientDetails.getScope(), "external"); //校验scope new DefaultOAuth2RequestValidator().validateScope(tokenRequest, clientDetails); OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails); OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication); OAuth2AccessToken oAuth2AccessToken = defaultAuthorizationServerTokenServices.createAccessToken(oAuth2Authentication); log.info(" 获取token 成功:{}", oAuth2AccessToken.getValue()); response.setCharacterEncoding("UTF-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); // APPLICATION_JSON_UTF8_VALUE is deprecated PrintWriter printWriter = response.getWriter(); printWriter.append(objectMapper.writeValueAsString(oAuth2AccessToken)); } catch ( IOException e) { throw new BadCredentialsException( "Failed to decode basic authentication token"); } clearAuthenticationAttributes(request); } protected final void clearAuthenticationAttributes(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session != null) { session.removeAttribute("SPRING_SECURITY_LAST_EXCEPTION"); } } }
- Configurer
package stoney.exer.spring.authsvr.exauth; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Lazy; import org.springframework.security.authentication.AuthenticationEventPublisher; 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.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.ClientDetailsService; import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import stoney.exer.spring.authsvr.service.MemoryUserService; @Setter @Getter @Slf4j public class Ex1AuthenticationConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired // 来自 MemoryUserService 的@Component private MemoryUserService userDetailsService; @Autowired // 来自框架 private AuthenticationEventPublisher defaultAuthenticationEventPublisher; @Lazy @Autowired // 来自本文件 @Bean定义 private AuthenticationSuccessHandler ex1LoginSuccessHandler; @Override public void configure(HttpSecurity http) throws Exception { log.debug("Configuring Ex1AuthenticationFilter"); Ex1AuthenticationFilter ex1AuthenticationFilter = new Ex1AuthenticationFilter("/ex1token","ex1token"); ex1AuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // ex1AuthenticationFilter.setEventPublisher(defaultAuthenticationEventPublisher); // ExtAuthenticationFilter.setAuthenticationEntryPoint(new ResourceAuthExceptionEntryPoint(objectMapper)); ex1AuthenticationFilter.setAuthenticationSuccessHandler(ex1LoginSuccessHandler); log.debug("LoginSuccessHandler for {} is {}",ex1AuthenticationFilter,ex1LoginSuccessHandler); Ex1AuthenticationProvider ex1AuthenticationProvider = new Ex1AuthenticationProvider(); ex1AuthenticationProvider.setUserDetailsService(userDetailsService); log.debug("UserDetailService for {} is {}",ex1AuthenticationProvider,userDetailsService); http // 加入 AuthenticationProvider 和 AuthenticationFilter .authenticationProvider(ex1AuthenticationProvider) .addFilterBefore(ex1AuthenticationFilter, UsernamePasswordAuthenticationFilter.class) ; } @Bean("ex1LoginSuccessHandler") public Ex1LoginSuccessHandler ex1LoginSuccessHandler( @Autowired ObjectMapper objectMapper, // 来自框架 @Autowired ClientDetailsService clientDetailsService, // 来自 ?? @Autowired PasswordEncoder passwordEncoder, // 来自SecurityConfig @Bean 定义 @Lazy @Autowired AuthorizationServerTokenServices defaultAuthorizationServerTokenServices) // 来自框架 { log.debug("~~~ ex1LoginSuccessHandler Bean init info:\n\tObject Mapper: {},\n\tclientDetailService: {}," + "\n\tPasswordEncoder: {},\n\tAuthorizationServerTokenServices: {}", objectMapper,clientDetailsService,passwordEncoder,defaultAuthorizationServerTokenServices); return Ex1LoginSuccessHandler.builder() .objectMapper(objectMapper) .clientDetailsService(clientDetailsService) .passwordEncoder(passwordEncoder) .defaultAuthorizationServerTokenServices(defaultAuthorizationServerTokenServices).build(); } }
- 其他工具类
package stoney.exer.common.auth; import lombok.SneakyThrows; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import javax.servlet.http.HttpServletRequest; import java.nio.charset.StandardCharsets; import java.util.Base64; /** * 认证授权相关工具类 */ @Slf4j @UtilityClass public class AuthUtils { private final String BASIC_ = "Basic "; /** * 从header 请求中的clientId/clientsecect * * @param header header中的参数 * @throws RuntimeException if the Basic header is not present or is not valid * Base64 */ @SneakyThrows public String[] extractAndDecodeHeader(String header) { byte[] base64Token = header.substring(6).getBytes("UTF-8"); byte[] decoded; try { decoded = Base64.getUrlDecoder().decode(base64Token); } catch (IllegalArgumentException e) { throw new RuntimeException( "Failed to decode basic authentication token"); } String token = new String(decoded, StandardCharsets.UTF_8); int delim = token.indexOf(":"); if (delim == -1) { throw new RuntimeException("Invalid basic authentication token"); } return new String[]{token.substring(0, delim), token.substring(delim + 1)}; } /** * *从header 请求中的clientId/clientsecect * * @param request * @return */ @SneakyThrows public String[] extractAndDecodeHeader(HttpServletRequest request) { String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (header == null || !header.startsWith(BASIC_)) { throw new RuntimeException("请求头中client信息为空"); } return extractAndDecodeHeader(header); } }
配置方法
在auth server原有的配置(WebSecurityConfigurerAdapter)中,加入:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// TODO: 此处略去原有配置若干行 ...
// 添加外部token支持的配置。若希望是无状态 token验证(比较严格),应加上SessionCreationPolicy.STATELESS 配置
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.apply(ex1AuthenticationConfigurer);
;
}
@Bean
public Ex1AuthenticationConfigurer ex1AuthenticationConfigurer()
{
Ex1AuthenticationConfigurer ex1AuthenticationConfigurer = new Ex1AuthenticationConfigurer();
return ex1AuthenticationConfigurer;
}
注意上述配置中,加入了sessionCreationPolicy(SessionCreationPolicy.STATELESS) 的内容,这是因为如果不这样设置,对应的请求会使用HttpSessionSecurityCHttpSessionSecurityContextRepositoryontextRepository存储session(而这还会在一些框架版本组合中引起tomcat的一个AbstractMethod异常),对于分布式服务环境来说没有必要。这样设置之后,框架会用NullSecurityContextRepository 去代替 HttpSessionSecurityContextRepository, 从而不在session中存储token,而要求每次请求必须带有token,强制验证。
请求方法
curl -X POST -i -u order-client:order-secret-8888 "http://localhost:6081/ex1token" -H "ex1token: 1234567890"
对比之前原有的请求:
curl -X POST -i "http://localhost:6081/oauth/token?grant_type=password&client_id=client1&client_secret=secret1&username=user1&password=123456&scope=all"