在上一節Spring Security OAuth2入門中,我們使用了Spring Security OAuth2封裝的授權碼和密碼模式成功獲取了令牌,這節記錄下如何通過自定義的用戶名密碼和手機短信驗證碼的方式來獲取令牌。
自定義用戶名密碼方式獲取令牌
在上一節的基礎上,我們先在資源服務器上加入一些基本的Spring Security配置:
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private MyAuthenticationSucessHandler authenticationSucessHandler;
@Autowired
private MyAuthenticationFailureHandler authenticationFailureHandler;
@Override
public void configure(HttpSecurity http) throws Exception {
http.formLogin() // 表單登錄
.loginProcessingUrl("/login") // 處理表單登錄 URL
.successHandler(authenticationSucessHandler) // 處理登錄成功
.failureHandler(authenticationFailureHandler) // 處理登錄失敗
.and()
.authorizeRequests() // 授權配置
.anyRequest() // 所有請求
.authenticated() // 都需要認證
.and()
.csrf().disable();
}
}
MyAuthenticationFailureHandler失敗處理器的邏輯很簡單,就是認證失敗放回相應提示:
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Autowired
private ObjectMapper mapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(mapper.writeValueAsString(exception.getMessage()));
}
}
問題的關鍵是,如何在登錄成功處理器里返回令牌。在研究Spring Security OAuth2自帶的令牌獲取方式后,會發現令牌的產生可以歸納為以下幾個步驟:
我們可以參考這個流程,來實現在登錄成功處理器MyAuthenticationSucessHandler里生成令牌並返回:
@Component
public class MyAuthenticationSucessHandler implements AuthenticationSuccessHandler {
private Logger log = LoggerFactory.getLogger(this.getClass());
@Autowired
private ClientDetailsService clientDetailsService;
@Autowired
private AuthorizationServerTokenServices authorizationServerTokenServices;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// 1. 從請求頭中獲取 ClientId
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Basic ")) {
throw new UnapprovedClientAuthenticationException("請求頭中無client信息");
}
String[] tokens = this.extractAndDecodeHeader(header, request);
String clientId = tokens[0];
String clientSecret = tokens[1];
TokenRequest tokenRequest = null;
// 2. 通過 ClientDetailsService 獲取 ClientDetails
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
// 3. 校驗 ClientId和 ClientSecret的正確性
if (clientDetails == null) {
throw new UnapprovedClientAuthenticationException("clientId:" + clientId + "對應的信息不存在");
} else if (!StringUtils.equals(clientDetails.getClientSecret(), clientSecret)) {
throw new UnapprovedClientAuthenticationException("clientSecret不正確");
} else {
// 4. 通過 TokenRequest構造器生成 TokenRequest
tokenRequest = new TokenRequest(new HashMap<>(), clientId, clientDetails.getScope(), "custom");
}
// 5. 通過 TokenRequest的 createOAuth2Request方法獲取 OAuth2Request
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
// 6. 通過 Authentication和 OAuth2Request構造出 OAuth2Authentication
OAuth2Authentication auth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
// 7. 通過 AuthorizationServerTokenServices 生成 OAuth2AccessToken
OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(auth2Authentication);
// 8. 返回 Token
log.info("登錄成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(token));
}
private String[] extractAndDecodeHeader(String header, HttpServletRequest request) {
byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(base64Token);
} catch (IllegalArgumentException var7) {
throw new BadCredentialsException("Failed to decode basic authentication token");
}
String token = new String(decoded, StandardCharsets.UTF_8);
int delim = token.indexOf(":");
if (delim == -1) {
throw new BadCredentialsException("Invalid basic authentication token");
} else {
return new String[]{token.substring(0, delim), token.substring(delim + 1)};
}
}
}
啟動項目,使用postman發送登錄請求localhost:8080/login:
點擊發送后便可以成功獲取到令牌:
{
"access_token": "88a3dd6c-ab27-41af-95ee-5cd406fe5ab1",
"token_type": "bearer",
"refresh_token": "b316177d-68e9-4fc9-9f4a-804a7367ebc9",
"expires_in": 43199
}
使用這個令牌便可以成功訪問/index接口,這里就不演示了。
短信驗證碼獲取令牌
在Spring Security短信驗證碼登錄一節中,我們實現了通過短信驗證碼登錄系統的功能,通過短信驗證碼獲取令牌和它唯一的區別就是驗證碼的存儲策略。之前的例子驗證碼存儲在Session中,現在使用令牌的方式和系統交互后Session已經不適用了,我們可以使用第三方存儲來保存我們的驗證碼(無論是短信驗證碼還是圖形驗證碼都是一個道理),比如Redis等。
引入Redis依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
定義一個RedisCodeService,用於驗證碼的增刪改:
/**
* Redis操作驗證碼服務
*/
@Service
public class RedisCodeService {
private final static String SMS_CODE_PREFIX = "SMS_CODE:";
private final static Integer TIME_OUT = 300;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 保存驗證碼到 redis
*
* @param smsCode 短信驗證碼
* @param request ServletWebRequest
*/
public void save(SmsCode smsCode, ServletWebRequest request, String mobile) throws Exception {
redisTemplate.opsForValue().set(key(request, mobile), smsCode.getCode(), TIME_OUT, TimeUnit.SECONDS);
}
/**
* 獲取驗證碼
*
* @param request ServletWebRequest
* @return 驗證碼
*/
public String get(ServletWebRequest request, String mobile) throws Exception {
return redisTemplate.opsForValue().get(key(request, mobile));
}
/**
* 移除驗證碼
*
* @param request ServletWebRequest
*/
public void remove(ServletWebRequest request, String mobile) throws Exception {
redisTemplate.delete(key(request, mobile));
}
private String key(ServletWebRequest request, String mobile) throws Exception {
String deviceId = request.getHeader("deviceId");
if (StringUtils.isBlank(deviceId)) {
throw new Exception("請在請求頭中設置deviceId");
}
return SMS_CODE_PREFIX + deviceId + ":" + mobile;
}
}
然后將Spring Security短信驗證碼登錄一節中的實現都挪到現在的Demo里,修改相應的地方(涉及到驗證碼的增刪改的地方,具體可以參考下面的源碼,這里就不贅述了)。
啟動系統,使用postman發送驗證碼:
請求頭中帶上deviceId(這里為隨便填寫的模擬值):
點擊發送后,控制台輸出:
手機號17720202020的登錄驗證碼為:619963,有效時間為120秒
接着用這個驗證碼去換取令牌,使用postman發送如下請求:
同樣請求頭中要帶上deviceId和經過base64加密的client_id:client_secret:
{
"access_token": "7fe22e67-1a11-4708-8707-0100555a9d1a",
"token_type": "bearer",
"refresh_token": "7c7a814f-2ace-4171-9748-56cb1994b04b",
"expires_in": 41982
}