學習思路
- 認證流程
- 基於Spring-Security原理講解
- 用戶名密碼模式具體代碼實現
- 根據用戶名密碼模式思考短信驗證碼模式
一、認證流程
用戶登錄、認證流程
- 用戶輸入用戶名密碼請求後臺服務
- 後臺驗證用戶名密碼是否正確
- 正確返回token,錯誤拋出異常
- 用戶拿到token請求非白名單服務
- 如果token正常訪問成功,如果token異常訪問失敗
題外話:有沒有剛剛入門寫demo的感覺
二、基於Spring-Security原理講解
首先看一下用戶名密碼認證的源碼時序圖
解釋:
- AuthencationManager對usernamePasswordAuthencationToken進行認證
- Manager又將token交給了與該token相匹配的AuthenticationProvider(可多個provider)
- Provider調用本地UserDetailService獲取用戶信息
- provider進行密碼校驗
- 校驗通過發送成功事件,進入成功後處理,失敗拋出異常
三、用戶名密碼模式具體代碼實現
由於Security默認實現了用戶名密碼模式
1、引入jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2、編寫Filter集成AbstractAuthenticationProcessingFilter,實現attemptAuthentication方法返回Authentication即可
public class UsernamePasswordFilter extends AbstractAuthenticationProcessingFilter {
@Autowired
LoginExtendProperty loginExtendProperty;
@Autowired
UserExtendService userExtendService;
/**
* 重新定義登陸地址
* @param authenticationManager
*/
public UsernamePasswordFilter(AuthenticationManager authenticationManager, String loginUrl) {
super(new AntPathRequestMatcher(loginUrl, HttpMethod.POST.name()));
setAuthenticationManager(authenticationManager);
setRequiresAuthenticationRequestMatcher(new RequestMatcher(MatcherRule.USERNAME_PASSWORD.getRuleName()));
}
/**
* 從body取用戶名密碼
* @return
* @throws AuthenticationServiceException
* @throws IOException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationServiceException, IOException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (!userExtendService.validate(request)){
throw new AuthenticationServiceException("業務驗證異常");
}
String json = StreamUtils.copyToString(request.getInputStream(), Charset.forName("UTF-8"));
logger.info(json);
JSONObject jsonObject;
if (null !=json && !"".equals(json) && json.contains(loginExtendProperty.getUsername()) && json.contains(loginExtendProperty.getPassword())){
jsonObject=JSONObject.parseObject(json);
}else {
throw new AuthenticationServiceException("用戶名或密碼錯誤");
}
return this.getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(
jsonObject.get(loginExtendProperty.getUsername()), jsonObject.get(loginExtendProperty.getPassword())
));
}
}
其中UsernamePasswordAuthenticationToken內部已經提供實現,我們只需要將用戶名密碼從request裏拿出來new對象即可
3、將配置Filter,編寫config繼承WebSecurityConfigurerAdapter
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@ComponentScan(basePackages = {"com.murphy.security"})
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthFilterProperty jwtAuthFilterProperty;
@Autowired
LoginExtendProperty loginExtendProperty;
@Autowired
private SimpleAuthenticatingSuccessHandler simpleAuthenticatingSuccessHandler;
@Autowired
private SimpleAuthenticatingFailureHandler simpleAuthenticatingFailureHandler;
@Autowired
UserExtendService userExtendService;
private Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* HTTP請求安全處理
* token請求授權
*
* @param httpSecurity .
* @throws Exception .
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// 由於使用的是JWT,我們這裏不需要csrf
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry expressionInterceptUrlRegistry
= httpSecurity.csrf().disable()
//未授權處理
.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
logger.error("未授權uri={}",request.getRequestURI());
response.setHeader("Access-Control-Allow-Origin", "*");
response.setStatus(200);
Map<String,Object> map=new HashMap<>(3);
map.put("code","401");
map.put("message","未授權uri="+request.getRequestURI());
map.put("data",Boolean.FALSE);
response.setCharacterEncoding("UTF-8");
JSONObject.writeJSONString(response.getWriter(),map);
})
// 基於token,所以不需要session
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().authorizeRequests();
//白名單,無需token
String[] urls = jwtAuthFilterProperty.getExceptUrl().split(",");
for (String url : urls) {
// 對於獲取token的rest api要允許匿名訪問
expressionInterceptUrlRegistry.antMatchers(url).permitAll();
}
expressionInterceptUrlRegistry
.antMatchers(HttpMethod.OPTIONS).permitAll()
// 除上面外的所有請求全部需要鑑權認證
.anyRequest().authenticated();
// 添加JWT filter
//將token驗證添加在密碼驗證前面
httpSecurity.addFilterBefore(getJwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
httpSecurity.addFilterBefore(getUsernamePasswordFilter(), JwtAuthenticationTokenFilter.class);
httpSecurity.authenticationProvider(new SmsAuthenticationProvider(userExtendService)).addFilterBefore(getSmsFilter(),UsernamePasswordFilter.class);
// 禁用緩存
httpSecurity.headers().cacheControl();
}
/**
* 密碼加密
* @author dongsufeng
* @return org.springframework.security.crypto.password.PasswordEncoder
* @date 2019/9/17 18:39
*/
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public JwtAuthenticationTokenFilter getJwtAuthenticationTokenFilter() throws Exception {
return new JwtAuthenticationTokenFilter();
}
@Bean
public AuthenticationManager getManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public UsernamePasswordFilter getUsernamePasswordFilter() throws Exception {
UsernamePasswordFilter userLoginFilter = new UsernamePasswordFilter(getManagerBean(), loginExtendProperty.getLoginUrl());
userLoginFilter.setAuthenticationSuccessHandler(simpleAuthenticatingSuccessHandler);
userLoginFilter.setAuthenticationFailureHandler(simpleAuthenticatingFailureHandler);
return userLoginFilter;
}
}
解釋:
- configure()方法是http安全請求處理,主要配置了一些白名單,那些攔截,Filter及順序
- getManagerBean()是獲取AuthenticationManager
- getUsernamePasswordFilter()裏邊配置了執行成功和失敗的Handler
4、編寫執行成功,失敗的Handler
/**
* 登陸成功後處理
* @author dongsufeng
* @date 2019/12/02 15:01
* @version 1.0
*/
@Component
public class SimpleAuthenticatingSuccessHandler implements AuthenticationSuccessHandler {
private static ObjectMapper globalObjectMapper = new ObjectMapper();
@Autowired
private JwtService jwtService;
static {
globalObjectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
private Logger logger = LoggerFactory.getLogger(SimpleAuthenticatingSuccessHandler.class);
private static void clearAuthenticationAttributes(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
/**
* 登陸成功返回token給用戶
* @param authentication
* @throws IOException
* @throws ServletException
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
Map<String, Object> result = new HashMap<>();
logger.info("登錄成功返回對象==={}",JSONObject.toJSONString(authentication.getPrincipal()));
result.put("token", jwtService.generateToken(authentication));
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
globalObjectMapper.writeValue(response.getWriter(), result);
clearAuthenticationAttributes(request);
}
}
解釋:
- 主要實現AuthenticationSuccessHandler接口實現onAuthenticationSuccess方法
- 成功後生成token:jwtService.generateToken(authentication)
- 封裝token返回
- 異常情況實現AuthenticationFailureHandler接口跟成功差不多
5、生成token,看註釋,可以適當將用戶信息放入
/**
* 生成令牌
*
* @param .
* @return .
*/
public String generateToken(Authentication authentication) {
return Jwts.builder()
//jwt簽發者
.setIssuer(jwtPayloadProperties.getIssuer())
// jwt所面向的用戶
.setSubject(authentication.getName())
//接收jwt的一方
.setAudience(jwtPayloadProperties.getAudience())
//生效時間
.setNotBefore(new Date(System.currentTimeMillis() - jwtPayloadProperties.getNotBeforeMinute() * 60 * 1000))
//失效時間
.setExpiration(new Date(System.currentTimeMillis() + jwtPayloadProperties.getExpirationMinute() * 60 * 1000))
//簽發時間
.setIssuedAt(new Date())
//祕鑰簽名
.signWith(SignatureAlgorithm.HS512, jwtPayloadProperties.getSecret())
.claim("userDTO", JSON.toJSONString(authentication.getPrincipal()))
.compact();
}
6、作爲使用者應該實現UserDetailsService接口實現loadUserByUsername()
@Component
@Log4j2
public class UserDetailsService implements UserExtendService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("18888888888".equals(username)) {
User user = new User("18888888888", PasswordEncoderFactories.createDelegatingPasswordEncoder().encode("123456"), "userid1111101111");
user.setMobile("13333333333");
return user;
}else {
return null;
}
}
}
這裏主要通過username查詢用戶信息,這個必須實現,主要用於providr裏獲取用戶信息
7、用戶拿到token後可以將token放入header然後請求相應的服務了,進入驗證環境
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtService jwtService;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtAuthFilterProperty jwtAuthFilterProperty;
/**
* 過濾器邏輯
*
* @param request .
* @param response .
* @param chain .
* @throws ServletException .
* @throws IOException ,
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.jwtAuthFilterProperty.getHeader());
String tokenHead = this.jwtAuthFilterProperty.getTokenHead();
if (authHeader != null && authHeader.startsWith(tokenHead)) {
String authToken = authHeader.substring(tokenHead.length());
if (jwtService.validateToken(authToken)) {
Claims claimsFromToken = jwtService.getClaimsFromToken(authToken);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(claimsFromToken.getSubject(), null, null);
String userDTO = claimsFromToken.get("userDTO", String.class);
log.info("=======lanjie={}",userDTO);
request.setAttribute("userInfoDTO", userDTO);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
解釋:
- 正常實現Filter,然後對token進行解析驗證
- 將UsernamePasswordAuthenticationToken放入SecurityContext即可
至此整個用戶名密碼驗證流程結束
四、根據用戶名密碼模式思考短信驗證碼模式
我們瞭解了用戶名密碼整體流程之後,再來看如果是短信驗證碼該怎麼做
我們來模仿整個看看思路:
- 寫一個短信的Token跟UsernamePasswordAuthenticationToken,用於設置一些用戶名等參數
- 寫一個provider去獲取用戶信息,並進行相關的校驗
然後其它的就跟用戶名密碼一樣了,,
1、編寫SmsAuthonticationToken繼承AbstractAuthenticationToken(直接複製UsernamePasswordAuthonticationToken將裏邊的密碼參數刪掉就可以了)
詳情可看源碼:com.murphy.security.token.SmsAuthenticationToken
2、編寫provider實現AuthenticationProvider
public class SmsAuthenticationProvider implements AuthenticationProvider {
// private UserDetailsService userDetailsService;
private UserExtendService userExtendService;
public SmsAuthenticationProvider(){}
public SmsAuthenticationProvider( UserExtendService userExtendService){
// this.userDetailsService=userDetailsService;
this.userExtendService = userExtendService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
/**
* 調用 {@link UserDetailsService}
*/
UserDetails user = userExtendService.loadUserByUsername((String)authenticationToken.getPrincipal());
if (Objects.isNull(user)) {
throw new InternalAuthenticationServiceException("手機號不存在");
}
if (!userExtendService.validate(authenticationToken.getRequest())){
throw new InternalAuthenticationServiceException("驗證碼錯誤");
}
SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(user, user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
這裏主要看userExtendService.validate(authenticationToken.getRequest())
驗證碼校驗屬於業務,有各種各樣的實現方案(比如放redis裏)這裏可以提供接口,讓使用方實現接口,我們在這裏調用一下
public interface UserExtendService extends UserDetailsService{
/**
* 業務校驗
* @param request
* @return
*/
default boolean validate(HttpServletRequest request){
return true;
};
}
然後再根據用戶名密碼那套流程,將usernamePasswordAuthenticationToken換成SmsAuthenticationToken;將SmsAuthenticationProvider放入配置即可
具體配置文件可看源碼:com.murphy.security.config.WebSecurityConfig
這樣如果再有其它的認證方式,畫瓢即可,驗證可以給默認,然後再暴露給使用者,可以重寫
源碼地址:https://gitee.com/carpentor/spring-cloud-example
公衆號主要記錄各種源碼、面試題、微服務技術棧,幫忙關注一波,非常感謝