2023-12-01修改:在session-data-redis(Github)分支中添加了基于spring-session-data-redis
的實現(xiàn),無需借助nonceId
來保持認證狀態(tài),該分支已去除所有nonceId
相關內容,需要注意的是axios
在初始化時需要添加配置withCredentials: true
,讓請求攜帶cookie。當然一些響應json的處理還是使用下方的內容。
今天的主題就是使用單獨部署的登錄頁面替換認證服務器默認的登錄頁面(前后端分離時使用前端的登錄頁面),目前在網上能搜到的很多都是理論,沒有很好的一個示例,我就按照我自己的想法寫了一個實現(xiàn),給大家提供一個思路,如果有什么問題或者更好的想法可以在評論區(qū)提出,謝謝。
實現(xiàn)思路分析
先看一下在默認情況下請求在框架中的跳轉情況
????Spring Authorization Server(Spring Security)框架默認使用session存儲用戶的認證信息,這樣在登錄以后重定向回請求授權接口時(/oauth2/authorize)處理該請求的過濾器可以從session中獲取到認證信息,從而走后邊的流程,但是當這一套放在單獨部署的登錄頁面中就不行了,在請求授權時哪怕登錄過也無法獲取到認證信息,因為他們不再是同一個session中了;所以關鍵點就在于怎么存儲、獲取認證信息。
先查看下框架怎么獲取認證信息
????在處理/oauth2/authorize接口的過濾器OAuth2AuthorizationEndpointFilter
中看一下實現(xiàn)邏輯,看一下對于認證信息的處理,如下圖
先由converter處理,之后再由provider處理,之后判斷認證信息是否已經認證過了,沒認證過不處理,交給后邊的過濾器處理,接下來看一下converter中的邏輯,如下圖所示
????如圖所示,這里直接從SecurityContextHolder
中獲取的認證信息,那么接下來就需要找一下它是怎么獲取認證信息并放入SecurityContextHolder
中的。
在OAuth2AuthorizationEndpointFilter
中打一個斷點,請求一下/oauth2/authorize接口,
斷點斷住以后查看一下過濾器鏈,發(fā)現(xiàn)在OAuth2AuthorizationEndpointFilter
之前有一個SecurityContextHolderFilter
過濾器,名字表達的特征很明顯,接下來看一下這個過濾器中的邏輯。
從斷點截圖中可以看出是從securityContextRepository中獲取的認證信息,然后通過securityContextHolderStrategy保存,看一下是不是在這里設置的認證信息。
斷點進入方法后發(fā)現(xiàn)將認證信息的context設置到了contextHolder中,那這里和SecurityContextHolder是同一個東西嗎?請接著往下看
SecurityContextHolder
的getContext方法是從當前類中的屬性獲取,接下來看一下securityContextHolderStrategy的定義
它是通過調用SecurityContextHolder的getContextHolderStrategy方法完成實例化的,看下這個方法
????追蹤到這里應該就差不多了,框架從securityContextRepository中獲取認證信息,然后通過securityContextHolderStrategy放入SecurityContextHolder中,讓后邊的過濾器可以直接從SecurityContextHolder中獲取認證信息。
獲取認證信息的地方結束了,接下來看一下存儲認證信息的地方,分析完獲取的地方,存儲的地方就很簡單了。
存儲認證信息
????看過之前文章或者其它關于登錄分析的文章應該知道,框架對于登錄的處理是基于UsernamePasswordAuthenticationFilter
和父類AbstractAuthenticationProcessingFilter
,在父類中調用子類的校驗,重點是認證成功后的處理,如下圖
認證成功后調用了successfulAuthentication方法,看一下該方法的實現(xiàn)
????其它的不是本篇文章的重點,主要是紅框中的代碼,這里將登陸后的認證信息存儲在securityContextRepository中。
????到這里邏輯就通了,登錄后將認證信息存儲在securityContextRepository中,訪問時從securityContextRepository中取出認證信息并放在SecurityContextHolder中,這樣就保持了登陸狀態(tài)。
改造分析
????使用前后端分離的登錄頁面,那么登錄接口就需要響應json了,不能再使用默認的成功/失敗處理了,所以要重寫登錄成功和失敗的處理器;重定向也不能由認證服務來重定向了,應該由前端重定向;存儲認證信息的容器也不能以session為主了,使用redis來替換session。
????????使用redis后沒有session了,也就不能確定請求是哪一個,本人拙見是在登錄時攜帶一個唯一字符串,請求成功后前端重定向至需要認證的請求時攜帶該唯一字符串,這樣請求時可以根據這個唯一字符串獲取到認證信息。
2023-07-11修改:
只要用戶在瀏覽器中訪問認證服務就會生成一個session,所以當請求授權時或者訪問其它需要登錄的接口時,通過未登錄處理重定向至登錄頁面時獲取當前請求的sessionId,放入重定向地址的參數中;瀏覽器中在登錄頁面輸入賬號密碼提交時攜帶地址欄中的sessionId;存取認證信息時獲取sessionId的順序為:請求頭 —>>> 請求參數 —>>> 當前session;這樣一來就可以通過session將認證服務和單獨部署的登錄頁面中的請求串聯(lián)起來了,詳細實現(xiàn)請看下方代碼
主要就是重定向時將當前請求放入target參數中,當前sessionId放入nonceId參數中
2023-07-21修改內容:oauth協(xié)議中有nonce參數,為防止沖突,nonce參數改為nonceId
思路清晰以后編碼就很快了
- 重寫登錄成功處理(響應json)。
- 重寫登錄失敗處理(響應json)。
- 重寫未登錄處理,重定向到登錄頁面時攜帶當前請求url。
- 重寫認證信息存取邏輯,使用redis存儲認證信息。
- 將以上內容添加到Spring Authorization Server配置中,使其在過濾器鏈中生效
- 前端代碼編寫,按照要求傳遞唯一字符串并在登錄成功后重定向至參數中攜帶的地址
代碼實現(xiàn)
1. 創(chuàng)建LoginSuccessHandler類并實現(xiàn)AuthenticationSuccessHandler接口
package com.example.authorization.handler;
import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 登錄成功處理類
*
* @author vains
*/
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
Result<String> success = Result.success();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(JsonUtils.objectCovertToJson(success));
response.getWriter().flush();
}
}
2. 創(chuàng)建LoginFailureHandler類并實現(xiàn)AuthenticationFailureHandler接口
package com.example.authorization.handler;
import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 登錄失敗處理類
*
* @author vains
*/
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
// 登錄失敗,寫回401與具體的異常
Result<String> success = Result.error(HttpStatus.UNAUTHORIZED.value(), exception.getMessage());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(JsonUtils.objectCovertToJson(success));
response.getWriter().flush();
}
}
3. 創(chuàng)建LoginTargetAuthenticationEntryPoint類并繼承LoginUrlAuthenticationEntryPoint類
2023-07-11添加邏輯:重定向地址添加nonce參數,該參數的值為sessionId,詳情請查看代碼
2023-07-21修改內容:oauth協(xié)議中有nonce參數,為防止沖突,nonce參數改為nonceId
package com.example.authorization.handler;
import com.example.constant.SecurityConstants;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.ObjectUtils;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 重定向至登錄處理
*
* @author vains
*/
@Slf4j
public class LoginTargetAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
/**
* @param loginFormUrl URL where the login page can be found. Should either be
* relative to the web-app context path (include a leading {@code /}) or an absolute
* URL.
*/
public LoginTargetAuthenticationEntryPoint(String loginFormUrl) {
super(loginFormUrl);
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 獲取登錄表單的地址
String loginForm = determineUrlToUseForThisRequest(request, response, authException);
if (!UrlUtils.isAbsoluteUrl(loginForm)) {
// 不是絕對路徑調用父類方法處理
super.commence(request, response, authException);
return;
}
StringBuffer requestUrl = request.getRequestURL();
if (!ObjectUtils.isEmpty(request.getQueryString())) {
requestUrl.append("?").append(request.getQueryString());
}
// 2023-07-11添加邏輯:重定向地址添加nonce參數,該參數的值為sessionId
// 絕對路徑在重定向前添加target參數
String targetParameter = URLEncoder.encode(requestUrl.toString(), StandardCharsets.UTF_8);
String targetUrl = loginForm + "?target=" + targetParameter + "&" + SecurityConstants.NONCE_HEADER_NAME + "=" + request.getSession(Boolean.FALSE).getId();
log.debug("重定向至前后端分離的登錄頁面:{}", targetUrl);
this.redirectStrategy.sendRedirect(request, response, targetUrl);
}
}
在SecurityConstants中添加NONCE_HEADER_NAME
2023-07-21修改內容:oauth協(xié)議中有nonce參數,為防止沖突,nonce參數改為nonceId
/**
* 隨機字符串請求頭名字
*/
public static final String NONCE_HEADER_NAME = "nonceId";
4. 在support包下創(chuàng)建RedisSecurityContextRepository并實現(xiàn)SecurityContextRepository
2023-07-11新增邏輯:如果請求頭與請求參數中獲取不到隨機字符串nonce則獲取當前session的sessionId,詳情請查看代碼
2023-07-21修改內容:oauth協(xié)議中有nonce參數,為防止沖突,nonce參數改為nonceId
package com.example.support;
import com.example.model.security.SupplierDeferredSecurityContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.DeferredSecurityContext;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.util.function.Supplier;
import static com.example.constant.RedisConstants.DEFAULT_TIMEOUT_SECONDS;
import static com.example.constant.RedisConstants.SECURITY_CONTEXT_PREFIX_KEY;
import static com.example.constant.SecurityConstants.NONCE_HEADER_NAME;
/**
* 基于redis存儲認證信息
*
* @author vains
*/
@Component
@RequiredArgsConstructor
public class RedisSecurityContextRepository implements SecurityContextRepository {
private final RedisOperator<SecurityContext> redisOperator;
private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
// HttpServletRequest request = requestResponseHolder.getRequest();
// return readSecurityContextFromRedis(request);
// 方法已過時,使用 loadDeferredContext 方法
throw new UnsupportedOperationException("Method deprecated.");
}
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
String nonce = getNonce(request);
if (ObjectUtils.isEmpty(nonce)) {
return;
}
// 如果當前的context是空的,則移除
SecurityContext emptyContext = this.securityContextHolderStrategy.createEmptyContext();
if (emptyContext.equals(context)) {
redisOperator.delete((SECURITY_CONTEXT_PREFIX_KEY + nonce));
} else {
// 保存認證信息
redisOperator.set((SECURITY_CONTEXT_PREFIX_KEY + nonce), context, DEFAULT_TIMEOUT_SECONDS);
}
}
@Override
public boolean containsContext(HttpServletRequest request) {
String nonce = getNonce(request);
if (ObjectUtils.isEmpty(nonce)) {
return false;
}
// 檢驗當前請求是否有認證信息
return redisOperator.get((SECURITY_CONTEXT_PREFIX_KEY + nonce)) != null;
}
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> readSecurityContextFromRedis(request);
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
}
/**
* 從redis中獲取認證信息
*
* @param request 當前請求
* @return 認證信息
*/
private SecurityContext readSecurityContextFromRedis(HttpServletRequest request) {
if (request == null) {
return null;
}
String nonce = getNonce(request);
if (ObjectUtils.isEmpty(nonce)) {
return null;
}
// 根據緩存id獲取認證信息
return redisOperator.get((SECURITY_CONTEXT_PREFIX_KEY + nonce));
}
/**
* 先從請求頭中找,找不到去請求參數中找,找不到獲取當前session的id
* 2023-07-11新增邏輯:獲取當前session的sessionId
*
* @param request 當前請求
* @return 隨機字符串(sessionId),這個字符串本來是前端生成,現(xiàn)在改為后端獲取的sessionId
*/
private String getNonce(HttpServletRequest request) {
String nonce = request.getHeader(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
nonce = request.getParameter(NONCE_HEADER_NAME);
HttpSession session = request.getSession(Boolean.FALSE);
if (ObjectUtils.isEmpty(nonce) && session != null) {
nonce = session.getId();
}
}
return nonce;
}
}
關于Redis存儲時的序列化問題可查看《Spring Authorization Server優(yōu)化篇:Redis值序列化器添加Jackson Mixin,解決Redis反序列化失敗問題》一文,現(xiàn)在尚未配置RedisTemplate的值序列化器,所以存入redis后使用客戶端查看時會看起來像亂碼的,但是如果配置了值序列化器又會反序列化失敗,所以需要使用Jackson Mixin的方式解決。
補充SupplierDeferredSecurityContext
類
該類默認包外無法訪問,將框架中的復制一份暴露出來
package com.example.model.security;
import java.util.function.Supplier;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.security.core.context.DeferredSecurityContext;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
/**
* @author Steve Riesenberg
* @since 5.8
*/
public final class SupplierDeferredSecurityContext implements DeferredSecurityContext {
private static final Log logger = LogFactory.getLog(SupplierDeferredSecurityContext.class);
private final Supplier<SecurityContext> supplier;
private final SecurityContextHolderStrategy strategy;
private SecurityContext securityContext;
private boolean missingContext;
public SupplierDeferredSecurityContext(Supplier<SecurityContext> supplier, SecurityContextHolderStrategy strategy) {
this.supplier = supplier;
this.strategy = strategy;
}
@Override
public SecurityContext get() {
init();
return this.securityContext;
}
@Override
public boolean isGenerated() {
init();
return this.missingContext;
}
private void init() {
if (this.securityContext != null) {
return;
}
this.securityContext = this.supplier.get();
this.missingContext = (this.securityContext == null);
if (this.missingContext) {
this.securityContext = this.strategy.createEmptyContext();
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Created %s", this.securityContext));
}
}
}
}
5. 將以上自己創(chuàng)建的類添加至security配置中
配置認證服務配置
// 主要是以下兩處配置
// 使用redis存儲、讀取登錄的認證信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
// 這里使用自定義的未登錄處理,并設置登錄地址為前端的登錄地址
http
// 當未登錄時訪問認證端點時重定向至login頁面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
// 這里使用自定義的未登錄處理,并設置登錄地址為前端的登錄地址
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
/**
* 配置端點的過濾器鏈
*
* @param http spring security核心配置類
* @return 過濾器鏈
* @throws Exception 拋出
*/
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,
RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings) throws Exception {
// 配置默認的設置,忽略認證端點的csrf校驗
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// 新建設備碼converter和provider
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
// 使用redis存儲、讀取登錄的認證信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 開啟OpenID Connect 1.0協(xié)議相關端點
.oidc(Customizer.withDefaults())
// 設置自定義用戶確認授權頁
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
// 設置設備碼用戶驗證url(自定義用戶驗證頁)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
deviceAuthorizationEndpoint.verificationUri("/activate")
)
// 設置驗證設備碼用戶確認頁面
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
// 客戶端認證添加設備碼的converter和provider
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
);
http
// 當未登錄時訪問認證端點時重定向至login頁面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// 處理使用access token訪問用戶信息端點和客戶端注冊端點
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));
// 自定義短信認證登錄轉換器
SmsCaptchaGrantAuthenticationConverter converter = new SmsCaptchaGrantAuthenticationConverter();
// 自定義短信認證登錄認證提供
SmsCaptchaGrantAuthenticationProvider provider = new SmsCaptchaGrantAuthenticationProvider();
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 讓認證服務器元數據中有自定義的認證方式
.authorizationServerMetadataEndpoint(metadata -> metadata.authorizationServerMetadataCustomizer(customizer -> customizer.grantType(SecurityConstants.GRANT_TYPE_SMS_CODE)))
// 添加自定義grant_type——短信認證登錄
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenRequestConverter(converter)
.authenticationProvider(provider));
DefaultSecurityFilterChain build = http.build();
// 從框架中獲取provider中所需的bean
OAuth2TokenGenerator<?> tokenGenerator = http.getSharedObject(OAuth2TokenGenerator.class);
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);
// 以上三個bean在build()方法之后調用是因為調用build方法時框架會嘗試獲取這些類,
// 如果獲取不到則初始化一個實例放入SharedObject中,所以要在build方法調用之后獲取
// 在通過set方法設置進provider中,但是如果在build方法之后調用authenticationProvider(provider)
// 框架會提示unsupported_grant_type,因為已經初始化完了,在添加就不會生效了
provider.setTokenGenerator(tokenGenerator);
provider.setAuthorizationService(authorizationService);
provider.setAuthenticationManager(authenticationManager);
return build;
}
配置認證相關的過濾器鏈(資源服務器配置)
配置地方跟上邊差不多,自定義登錄成功/失敗處理,使用redis替換session的存儲,因為前后端分離了,還要配置解決跨域問題的過濾器,并禁用cors與csrf。
跨域過濾器一定要添加至security配置中,不然只注入ioc中對于security端點不生效!
跨域過濾器一定要添加至security配置中,不然只注入ioc中對于security端點不生效!
跨域過濾器一定要添加至security配置中,不然只注入ioc中對于security端點不生效!
/**
* 配置認證相關的過濾器鏈
*
* @param http spring security核心配置類
* @return 過濾器鏈
* @throws Exception 拋出
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// 添加跨域過濾器
http.addFilter(corsFilter());
// 禁用 csrf 與 cors
http.csrf(AbstractHttpConfigurer::disable);
http.cors(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests((authorize) -> authorize
// 放行靜態(tài)資源
.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha", "/getSmsCaptcha").permitAll()
.anyRequest().authenticated()
)
// 指定登錄頁面
.formLogin(formLogin ->
formLogin.loginPage("/login")
// 登錄成功和失敗改為寫回json,不重定向了
.successHandler(new LoginSuccessHandler())
.failureHandler(new LoginFailureHandler())
);
// 添加BearerTokenAuthenticationFilter,將認證服務當做一個資源服務,解析請求頭中的token
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.accessDeniedHandler(SecurityUtils::exceptionHandler)
.authenticationEntryPoint(SecurityUtils::exceptionHandler)
);
http
// 當未登錄時訪問認證端點時重定向至login頁面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
// 使用redis存儲、讀取登錄的認證信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
return http.build();
}
在AuthorizationConfig中添加跨域過濾器
/**
* 跨域過濾器配置
*
* @return CorsFilter
*/
@Bean
public CorsFilter corsFilter() {
// 初始化cors配置對象
CorsConfiguration configuration = new CorsConfiguration();
// 設置允許跨域的域名,如果允許攜帶cookie的話,路徑就不能寫*號, *表示所有的域名都可以跨域訪問
configuration.addAllowedOrigin("http://127.0.0.1:5173");
// 設置跨域訪問可以攜帶cookie
configuration.setAllowCredentials(true);
// 允許所有的請求方法 ==> GET POST PUT Delete
configuration.addAllowedMethod("*");
// 允許攜帶任何頭信息
configuration.addAllowedHeader("*");
// 初始化cors配置源對象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
// 給配置源對象設置過濾的參數
// 參數一: 過濾的路徑 == > 所有的路徑都要求校驗是否跨域
// 參數二: 配置類
configurationSource.registerCorsConfiguration("/**", configuration);
// 返回配置好的過濾器
return new CorsFilter(configurationSource);
}
RedisConstants中添加常量
/**
* 認證信息存儲前綴
*/
public static final String SECURITY_CONTEXT_PREFIX_KEY = "security_context:";
如果沒有RedisOperator
可以看下我之前的優(yōu)化篇
完整的AuthorizationConfig如下
package com.example.config;
import com.example.authorization.device.DeviceClientAuthenticationConverter;
import com.example.authorization.device.DeviceClientAuthenticationProvider;
import com.example.authorization.handler.LoginFailureHandler;
import com.example.authorization.handler.LoginSuccessHandler;
import com.example.authorization.handler.LoginTargetAuthenticationEntryPoint;
import com.example.authorization.sms.SmsCaptchaGrantAuthenticationConverter;
import com.example.authorization.sms.SmsCaptchaGrantAuthenticationProvider;
import com.example.constant.SecurityConstants;
import com.example.support.RedisSecurityContextRepository;
import com.example.util.SecurityUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;
/**
* 認證配置
* {@link EnableMethodSecurity} 開啟全局方法認證,啟用JSR250注解支持,啟用注解 {@link Secured} 支持,
* 在Spring Security 6.0版本中將@Configuration注解從@EnableWebSecurity, @EnableMethodSecurity, @EnableGlobalMethodSecurity
* 和 @EnableGlobalAuthentication 中移除,使用這些注解需手動添加 @Configuration 注解
* {@link EnableWebSecurity} 注解有兩個作用:
* 1. 加載了WebSecurityConfiguration配置類, 配置安全認證策略。
* 2. 加載了AuthenticationConfiguration, 配置了認證信息。
*
* @author vains
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class AuthorizationConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
private final RedisSecurityContextRepository redisSecurityContextRepository;
/**
* 配置端點的過濾器鏈
*
* @param http spring security核心配置類
* @return 過濾器鏈
* @throws Exception 拋出
*/
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,
RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings) throws Exception {
// 配置默認的設置,忽略認證端點的csrf校驗
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// 新建設備碼converter和provider
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
// 使用redis存儲、讀取登錄的認證信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 開啟OpenID Connect 1.0協(xié)議相關端點
.oidc(Customizer.withDefaults())
// 設置自定義用戶確認授權頁
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
// 設置設備碼用戶驗證url(自定義用戶驗證頁)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
deviceAuthorizationEndpoint.verificationUri("/activate")
)
// 設置驗證設備碼用戶確認頁面
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
// 客戶端認證添加設備碼的converter和provider
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
);
http
// 當未登錄時訪問認證端點時重定向至login頁面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// 處理使用access token訪問用戶信息端點和客戶端注冊端點
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));
// 自定義短信認證登錄轉換器
SmsCaptchaGrantAuthenticationConverter converter = new SmsCaptchaGrantAuthenticationConverter();
// 自定義短信認證登錄認證提供
SmsCaptchaGrantAuthenticationProvider provider = new SmsCaptchaGrantAuthenticationProvider();
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 讓認證服務器元數據中有自定義的認證方式
.authorizationServerMetadataEndpoint(metadata -> metadata.authorizationServerMetadataCustomizer(customizer -> customizer.grantType(SecurityConstants.GRANT_TYPE_SMS_CODE)))
// 添加自定義grant_type——短信認證登錄
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenRequestConverter(converter)
.authenticationProvider(provider));
DefaultSecurityFilterChain build = http.build();
// 從框架中獲取provider中所需的bean
OAuth2TokenGenerator<?> tokenGenerator = http.getSharedObject(OAuth2TokenGenerator.class);
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);
// 以上三個bean在build()方法之后調用是因為調用build方法時框架會嘗試獲取這些類,
// 如果獲取不到則初始化一個實例放入SharedObject中,所以要在build方法調用之后獲取
// 在通過set方法設置進provider中,但是如果在build方法之后調用authenticationProvider(provider)
// 框架會提示unsupported_grant_type,因為已經初始化完了,在添加就不會生效了
provider.setTokenGenerator(tokenGenerator);
provider.setAuthorizationService(authorizationService);
provider.setAuthenticationManager(authenticationManager);
return build;
}
/**
* 配置認證相關的過濾器鏈
*
* @param http spring security核心配置類
* @return 過濾器鏈
* @throws Exception 拋出
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// 添加跨域過濾器
http.addFilter(corsFilter());
// 禁用 csrf 與 cors
http.csrf(AbstractHttpConfigurer::disable);
http.cors(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests((authorize) -> authorize
// 放行靜態(tài)資源
.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha", "/getSmsCaptcha").permitAll()
.anyRequest().authenticated()
)
// 指定登錄頁面
.formLogin(formLogin ->
formLogin.loginPage("/login")
// 登錄成功和失敗改為寫回json,不重定向了
.successHandler(new LoginSuccessHandler())
.failureHandler(new LoginFailureHandler())
);
// 添加BearerTokenAuthenticationFilter,將認證服務當做一個資源服務,解析請求頭中的token
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.accessDeniedHandler(SecurityUtils::exceptionHandler)
.authenticationEntryPoint(SecurityUtils::exceptionHandler)
);
http
// 當未登錄時訪問認證端點時重定向至login頁面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
// 使用redis存儲、讀取登錄的認證信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
return http.build();
}
/**
* 跨域過濾器配置
*
* @return CorsFilter
*/
@Bean
public CorsFilter corsFilter() {
// 初始化cors配置對象
CorsConfiguration configuration = new CorsConfiguration();
// 設置允許跨域的域名,如果允許攜帶cookie的話,路徑就不能寫*號, *表示所有的域名都可以跨域訪問
configuration.addAllowedOrigin("http://127.0.0.1:5173");
// 設置跨域訪問可以攜帶cookie
configuration.setAllowCredentials(true);
// 允許所有的請求方法 ==> GET POST PUT Delete
configuration.addAllowedMethod("*");
// 允許攜帶任何頭信息
configuration.addAllowedHeader("*");
// 初始化cors配置源對象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
// 給配置源對象設置過濾的參數
// 參數一: 過濾的路徑 == > 所有的路徑都要求校驗是否跨域
// 參數二: 配置類
configurationSource.registerCorsConfiguration("/**", configuration);
// 返回配置好的過濾器
return new CorsFilter(configurationSource);
}
/**
* 自定義jwt,將權限信息放至jwt中
*
* @return OAuth2TokenCustomizer的實例
*/
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer() {
return context -> {
// 檢查登錄用戶信息是不是UserDetails,排除掉沒有用戶參與的流程
if (context.getPrincipal().getPrincipal() instanceof UserDetails user) {
// 獲取申請的scopes
Set<String> scopes = context.getAuthorizedScopes();
// 獲取用戶的權限
Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
// 提取權限并轉為字符串
Set<String> authoritySet = Optional.ofNullable(authorities).orElse(Collections.emptyList()).stream()
// 獲取權限字符串
.map(GrantedAuthority::getAuthority)
// 去重
.collect(Collectors.toSet());
// 合并scope與用戶信息
authoritySet.addAll(scopes);
JwtClaimsSet.Builder claims = context.getClaims();
// 將權限信息放入jwt的claims中(也可以生成一個以指定字符分割的字符串放入)
claims.claim(SecurityConstants.AUTHORITIES_KEY, authoritySet);
// 放入其它自定內容
// 角色、頭像...
}
};
}
/**
* 自定義jwt解析器,設置解析出來的權限信息的前綴與在jwt中的key
*
* @return jwt解析器 JwtAuthenticationConverter
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
// 設置解析權限信息的前綴,設置為空是去掉前綴
grantedAuthoritiesConverter.setAuthorityPrefix("");
// 設置權限信息在jwt claims中的key
grantedAuthoritiesConverter.setAuthoritiesClaimName(SecurityConstants.AUTHORITIES_KEY);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
/**
* 將AuthenticationManager注入ioc中,其它需要使用地方可以直接從ioc中獲取
*
* @param authenticationConfiguration 導出認證配置
* @return AuthenticationManager 認證管理器
*/
@Bean
@SneakyThrows
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 配置密碼解析器,使用BCrypt的方式對密碼進行加密和驗證
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 配置客戶端Repository
*
* @param jdbcTemplate db 數據源信息
* @param passwordEncoder 密碼解析器
* @return 基于數據庫的repository
*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
// 客戶端id
.clientId("messaging-client")
// 客戶端秘鑰,使用密碼解析器加密
.clientSecret(passwordEncoder.encode("123456"))
// 客戶端認證方式,基于請求頭的認證
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
// 配置資源服務器使用該客戶端獲取授權時支持的方式
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
// 客戶端添加自定義認證
.authorizationGrantType(new AuthorizationGrantType(SecurityConstants.GRANT_TYPE_SMS_CODE))
// 授權碼模式回調地址,oauth2.1已改為精準匹配,不能只設置域名,并且屏蔽了localhost,本機使用127.0.0.1訪問
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
.redirectUri("https://www.baidu.com")
// 該客戶端的授權范圍,OPENID與PROFILE是IdToken的scope,獲取授權時請求OPENID的scope時認證服務會返回IdToken
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
// 自定scope
.scope("message.read")
.scope("message.write")
// 客戶端設置,設置用戶需要確認授權
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
// 基于db存儲客戶端,還有一個基于內存的實現(xiàn) InMemoryRegisteredClientRepository
JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
// 初始化客戶端
RegisteredClient repositoryByClientId = registeredClientRepository.findByClientId(registeredClient.getClientId());
if (repositoryByClientId == null) {
registeredClientRepository.save(registeredClient);
}
// 設備碼授權客戶端
RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("device-message-client")
// 公共客戶端
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
// 設備碼授權
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// 自定scope
.scope("message.read")
.scope("message.write")
.build();
RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClient.getClientId());
if (byClientId == null) {
registeredClientRepository.save(deviceClient);
}
// PKCE客戶端
RegisteredClient pkceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("pkce-message-client")
// 公共客戶端
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
// 授權碼模式,因為是擴展授權碼流程,所以流程還是授權碼的流程,改變的只是參數
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// 授權碼模式回調地址,oauth2.1已改為精準匹配,不能只設置域名,并且屏蔽了localhost,本機使用127.0.0.1訪問
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
.clientSettings(ClientSettings.builder().requireProofKey(Boolean.TRUE).build())
// 自定scope
.scope("message.read")
.scope("message.write")
.build();
RegisteredClient findPkceClient = registeredClientRepository.findByClientId(pkceClient.getClientId());
if (findPkceClient == null) {
registeredClientRepository.save(pkceClient);
}
return registeredClientRepository;
}
/**
* 配置基于db的oauth2的授權管理服務
*
* @param jdbcTemplate db數據源信息
* @param registeredClientRepository 上邊注入的客戶端repository
* @return JdbcOAuth2AuthorizationService
*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
// 基于db的oauth2認證服務,還有一個基于內存的服務實現(xiàn)InMemoryOAuth2AuthorizationService
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
/**
* 配置基于db的授權確認管理服務
*
* @param jdbcTemplate db數據源信息
* @param registeredClientRepository 客戶端repository
* @return JdbcOAuth2AuthorizationConsentService
*/
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
// 基于db的授權確認管理服務,還有一個基于內存的服務實現(xiàn)InMemoryOAuth2AuthorizationConsentService
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
/**
* 配置jwk源,使用非對稱加密,公開用于檢索匹配指定選擇器的JWK的方法
*
* @return JWKSource
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
/**
* 生成rsa密鑰對,提供給jwk
*
* @return 密鑰對
*/
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
/**
* 配置jwt解析器
*
* @param jwkSource jwk源
* @return JwtDecoder
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
* 添加認證服務器配置,設置jwt簽發(fā)者、默認端點請求地址等
*
* @return AuthorizationServerSettings
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
/*
設置token簽發(fā)地址(http(s)://{ip}:{port}/context-path, http(s)://domain.com/context-path)
如果需要通過ip訪問這里就是ip,如果是有域名映射就填域名,通過什么方式訪問該服務這里就填什么
*/
.issuer("http://192.168.120.33:8080")
.build();
}
}
6. 編寫登錄頁面
前端有以下幾點要求1. 請求登錄接口時需要附加一個key為nonce
的參數,請求頭或請求體中都可以
- 請求登錄接口之前獲取當前地址欄中的
noncenonceId
參數,將其添加至請求頭或請求體中。 - 請求成功后獲取當前頁面地址欄中的
target
的參數,重定向到該地址,并攜帶剛才登錄時攜帶的nonce~~nonceId
參數 - 登錄時區(qū)分密碼模式登錄與短信認證登錄
2023-07-11修改內容:在請求登錄接口時攜帶的nonce值從前端自己生成的隨機字符串改為后端重定向過來時攜帶的nonce
參數
2023-07-21修改內容:oauth協(xié)議中有nonce參數,為防止沖突,nonce參數改為nonceId
新建一個vue項目,引入axios和naive ui,這里只給出登錄頁面的代碼,稍后我會將前端的代碼上傳至gitee
<script setup lang="ts">
import { ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
import axios from 'axios'
import { type CountdownProps, createDiscreteApi } from 'naive-ui'
const { message } = createDiscreteApi(['message'])
// 定義登錄提交的對象
const loginModel = ref({
code: '',
username: '',
password: '',
loginType: '',
captchaId: '',
nonce: getQueryString('nonceId')
})
// 圖形驗證碼的base64數據
let captchaImage = ref('')
// 圖形驗證碼的值
let captchaCode = ''
// 是否開始倒計時
const counterActive = ref(false)
/**
* 獲取圖形驗證碼
*/
const getCaptcha = () => {
axios({
method: 'GET',
url: 'http://192.168.1.102:8080/getCaptcha'
}).then((r) => {
let result = r.data
if (result.success) {
captchaCode = result.data.code
captchaImage.value = result.data.imageData
loginModel.value.captchaId = result.data.captchaId
} else {
message.warning(result.message)
}
})
}
/**
* 提交登錄表單
*/
const submitLogin = () => {
loginModel.value.loginType = 'passwordLogin'
axios({
method: 'post',
url: 'http://192.168.1.102:8080/login',
headers: {
nonceId: loginModel.value.nonce,
'Content-Type': 'application/x-www-form-urlencoded'
},
data: loginModel.value
}).then((r) => {
let result = r.data
if (result.success) {
// message.info(`登錄成功`)
let target = getQueryString('target')
if (target) {
window.location.href = target
}
} else {
message.warning(result.message)
}
})
}
/**
* 提交短信登錄表單
*/
const submitSmsLogin = () => {
loginModel.value.loginType = 'smsCaptcha'
axios({
method: 'post',
url: 'http://192.168.1.102:8080/login',
headers: {
nonceId: loginModel.value.nonce,
'Content-Type': 'application/x-www-form-urlencoded'
},
data: loginModel.value
}).then((r) => {
let result = r.data
if (result.success) {
message.info(`登錄成功`)
let target = getQueryString('target')
if (target) {
window.location.href = target
}
} else {
message.warning(result.message)
}
})
}
/**
* 獲取短信驗證碼
*/
const getSmsCaptcha = () => {
if (!loginModel.value.username) {
message.warning('請先輸入手機號.')
return
}
if (!loginModel.value.code) {
message.warning('請先輸入驗證碼.')
return
}
if (loginModel.value.code !== captchaCode) {
message.warning('驗證碼錯誤.')
return
}
axios({
method: 'get',
url: `http://192.168.1.102:8080/getSmsCaptcha?phone=${loginModel.value.username}`
}).then((r) => {
let result = r.data
if (result.success) {
message.info(`獲取短信驗證碼成功,固定為:${result.data}`)
counterActive.value = true
} else {
message.warning(result.message)
}
})
}
/**
* 切換時更新驗證碼
*/
const handleUpdateValue = () => {
getCaptcha()
}
/**
* 倒計時結束
*/
const onFinish = () => {
counterActive.value = false
}
/**
* 倒計時顯示內容
*/
const renderCountdown: CountdownProps['render'] = ({ hours, minutes, seconds }) => {
return `${seconds}`
}
/**
* 獲取地址欄參數
* @param name 地址欄參數的key
*/
function getQueryString(name: string) {
var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i')
var r = window.location.search.substr(1).match(reg)
if (r != null) {
return unescape(r[2])
}
return null
}
getCaptcha()
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="統(tǒng)一認證平臺" />
</div>
</header>
<main>
<n-card title="">
<n-tabs
default-value="signin"
size="large"
justify-content="space-evenly"
@update:value="handleUpdateValue"
>
<n-tab-pane name="signin" tab="賬號登錄">
<n-form>
<n-form-item-row label="用戶名">
<n-input v-model:value="loginModel.username" placeholder="手機號 / 郵箱" />
</n-form-item-row>
<n-form-item-row label="密碼">
<n-input
v-model:value="loginModel.password"
type="password"
show-password-on="mousedown"
placeholder="密碼"
/>
</n-form-item-row>
<n-form-item-row label="驗證碼">
<n-input-group>
<n-input v-model:value="loginModel.code" placeholder="請輸入驗證碼" />
<n-image
@click="getCaptcha"
width="130"
height="34"
:src="captchaImage"
preview-disabled
/>
</n-input-group>
</n-form-item-row>
</n-form>
<n-button type="info" @click="submitLogin" block strong> 登錄 </n-button>
</n-tab-pane>
<n-tab-pane name="signup" tab="短信登錄">
<n-form>
<n-form-item-row label="手機號">
<n-input v-model:value="loginModel.username" placeholder="手機號 / 郵箱" />
</n-form-item-row>
<n-form-item-row label="驗證碼">
<n-input-group>
<n-input v-model:value="loginModel.code" placeholder="請輸入驗證碼" />
<n-image
@click="getCaptcha"
width="130"
height="34"
:src="captchaImage"
preview-disabled
/>
</n-input-group>
</n-form-item-row>
<n-form-item-row label="驗證碼">
<n-input-group>
<n-input v-model:value="loginModel.password" placeholder="請輸入驗證碼" />
<n-button
type="info"
@click="getSmsCaptcha"
style="width: 130px"
:disabled="counterActive"
>
獲取驗證碼
<span v-if="counterActive">
(
<n-countdown
:render="renderCountdown"
:on-finish="onFinish"
:duration="59 * 1000"
:active="counterActive"
/>
)</span
>
</n-button>
</n-input-group>
</n-form-item-row>
</n-form>
<n-button type="info" @click="submitSmsLogin" block strong> 登錄 </n-button>
</n-tab-pane>
</n-tabs>
</n-card>
</main>
</template>
<style scoped>
header {
line-height: 1.5;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
}
</style>
????示例項目只是一個很簡陋的頁面,大家使用自己的頁面即可(如果有前端大佬整個漂亮的登錄頁面就好了,求一個漂亮的登錄頁面)
到此為止,編碼部分就結束了,接下來就該測試了(老三樣了,理論、編碼、測試)
測試
組裝url發(fā)起授權請求
http://192.168.1.102:8080/oauth2/authorize?client_id=messaging-client&response_type=code&scope=message.read&redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Flogin%2Foauth2%2Fcode%2Fmessaging-client-oidc
檢測到未登錄,重定向至vue項目的登錄頁面
重定向時將當前請求放入target參數中
重定向時將當前請求放入target參數中,當前sessionId放入nonce參數中
查看一下network,認證服務按規(guī)則攜帶target與nonce參數重定向,沒有問題
輸入賬號密碼提交
查看network
??????登錄成功后會重定向回/oauth2/authorize
接口并攜帶nonce
參數,/oauth2/authorize
根據nonce
獲取到認證信息后會生成一個code,然后攜帶code跳轉至回調地址。
??????不需要攜帶任何參數,因為在重定向至登錄之前已經獲取到sessionId,并根據sessionId存儲登錄時的認證信息,所以重定向回/oauth2/authorize接口時能夠根據sessionId獲取到認證信息。獲取到認證信息后檢測到未授權確認,重定向至授權確認頁面。
授權確認提交
授權確認后生成code并攜帶code重定向至回調地址(redirect_uri)
寫在最后
????????在踩了不知道多少坑以后終于算是實現(xiàn)了這個東西,但是目前只支持不需要授權確認的客戶端,如果需要授權確認那么就會在重定向至授權確認頁面時因為獲取不到登錄信息而重定向至登錄頁面,這里也比較坑,沒有能夠更改授權確認請求的地方,只能另辟蹊徑修改現(xiàn)在的RedisSecurityContextRepository從而使授權確認請求也能獲取到認證信息,就是在獲取認證信息后存入session中一份,因為從/oauth2/authorize
重定向至授權確認頁面是同一個session,所以存入session后就可以獲取了,但是我覺得既然都已經前后端分離了,也就沒必要在加授權確認了;當然也不排除有需要這東西的人,后期看看有沒有需要的,如果有需要的我會寫一下擴展篇。
????????現(xiàn)在改為通過sessionId串聯(lián)認證服務與單獨部署的登錄頁面的請求,也就不會出現(xiàn)只能獲取一次認證信息的問題了,只要在同一個瀏覽器中訪問認證服務,那么使用的session就只會是同一個,當從其它系統(tǒng)跳轉至認證服務時只要登錄過就不需要在登錄了,可以直接根據瀏覽器與認證服務之間產生的session的id獲取到對應的認證信息,認證信息的存活時間就是在redis中設置的key的存活時間。
????????雖然現(xiàn)在也是靠session關聯(lián),但現(xiàn)在將原先存儲在session中的認證信息存儲到了redis中,縮小了服務器存儲session所需的空間,也可以通過sessionId將其關聯(lián)起來,解決了認證服務與登錄頁面不在同一域從而因為session的不同而獲取不到認證信息的問題。這也符合sso的特點,其它域名下的服務需要認證時需要跳轉到登錄頁面登錄,登錄后另外的服務再次請求認證服務認證時就不需要認證了,可以直接獲取到認證信息。
????????本來想寫點基礎的東西,但是兄弟們太相信我了,凈整些高端操作,唉 (╯°Д°)╯︵ ┻━┻
文章來源:http://www.zghlxwxcb.cn/news/detail-643980.html
要是有問題請在評論區(qū)提出,以防誤人子弟
代碼已提交至Gitee的授權碼模式前后端分離的登錄頁面
分支,如果有遺漏內容大家也可以拉取一下完整代碼看看。
倉庫地址文章來源地址http://www.zghlxwxcb.cn/news/detail-643980.html
到了這里,關于Spring Authorization Server入門 (十二) 實現(xiàn)授權碼模式使用前后端分離的登錄頁面的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網!