Spring Authorization Server擴(kuò)展密碼模式

新項(xiàng)目都開始用JDK17了鄙麦,Springboot也用3.x了,對(duì)應(yīng)的認(rèn)證授權(quán)服務(wù)也要升級(jí)了镊折。Spring OAuth2.0不再支持了胯府,開始使用Spring Authorization Server了。因?yàn)槿サ袅嗣艽a模式恨胚,而我們項(xiàng)目最適用的還是密碼模式盟劫,這里我們使用OAuth2.1的擴(kuò)展功能補(bǔ)上密碼模式。
參考官方文檔与纽,給出了步驟:

image.png

此次使用Springboot版本為:3.3.3
依賴引入:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.32</version>
</dependency>

1. 密碼模式

按照文檔,添加密碼模式塘装,主要添加幾個(gè)文件:

    1. Converter類:主要是限定請(qǐng)求Token的參數(shù)的急迂,并未驗(yàn)證參數(shù)的值,驗(yàn)證好參數(shù)后蹦肴,會(huì)將參數(shù)封裝成passwordToken實(shí)體僚碎,往下傳
    1. Provider類,主要作用2個(gè)阴幌,1是驗(yàn)證參數(shù)值勺阐,2是用這些數(shù)據(jù)生成Token。數(shù)據(jù)來源就是Converter類傳過來的passwordToken實(shí)體
    1. passwordToken實(shí)體:實(shí)體類功能矛双,有一些必須得屬性
    1. 密碼模式用到的工具類util

以上內(nèi)容都是參考授權(quán)碼模式或是客戶端模式來寫的渊抽。

1.1 Converter類 - OAuth2PasswordAuthenticationConverter
/**
 * 限定必須要傳的參數(shù)
 * 只是要這些參數(shù),并沒有對(duì)參數(shù)值校驗(yàn)
 */
public class OAuth2PasswordAuthenticationConverter implements AuthenticationConverter {

    @Nullable
    @Override
    public Authentication convert(HttpServletRequest request) {
        // 1. 提取表單參數(shù)议忽,準(zhǔn)備校驗(yàn)用懒闷,用的這個(gè)方法就是復(fù)制的sas自帶的
        MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getFormParameters(request);
        // 2. 授權(quán)類型參數(shù) (必須)
//      String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
        String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE);
        if (!OAuth2PasswordAuthenticationToken.PASSWORD.getValue().equals(grantType)) {
            return null;
        }
        // 3. 令牌申請(qǐng)?jiān)L問范圍參數(shù) (可選)
        String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
        if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
            OAuth2EndpointUtils.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.SCOPE,
                    OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
        }

        Set<String> requestedScopes = null;
        if (StringUtils.hasText(scope)) {
            requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
        }
        // 4. 賬號(hào)密碼參數(shù)校驗(yàn)
        // 4.1 用戶名參數(shù) (必須)
        String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);
        if (!StringUtils.hasText(username) || parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) {
            OAuth2EndpointUtils.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.USERNAME,
                    OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
        }

        // 4.2 密碼參數(shù) (必須)
        String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);
        if (!StringUtils.hasText(password) || parameters.get(OAuth2ParameterNames.PASSWORD).size() != 1) {
            OAuth2EndpointUtils.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.PASSWORD,
                    OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
        }

        // 5. 客戶端憑據(jù)信息,在header 中填寫的那個(gè)
        Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();

        // 6. 參數(shù)封裝成additionalParameters放到OAuth2PasswordAuthenticationToken中傳給 PasswordAuthenticationProvider 用于驗(yàn)證值
        Map<String, Object> additionalParameters = new HashMap<>();
        parameters.forEach((key, value) -> {
            if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && !key.equals(OAuth2ParameterNames.SCOPE)) {
                additionalParameters.put(key, value.get(0));
            }
        });
        return new OAuth2PasswordAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters);
    }

}
1.2 Provider類 - OAuth2PasswordAuthenticationProvider
/**
 * 參考授權(quán)碼(主要)"OAuth2AuthorizationCodeAuthenticationProvider"和客戶端"OAuth2ClientCredentialsAuthenticationProvider"
 */
public class OAuth2PasswordAuthenticationProvider implements AuthenticationProvider {
    
    @Resource
    private PasswordEncoder passwordEncoder;
    
    // 這部分代碼和OAuth2ClientCredentialsAuthenticationProvider類似,只是添加了AuthenticationManager
    private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";

    private final AuthenticationManager authenticationManager;
    private final OAuth2AuthorizationService authorizationService;
    private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
    
    
    public OAuth2PasswordAuthenticationProvider(AuthenticationManager authenticationManager, 
                                OAuth2AuthorizationService authorizationService,
                                OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {

        Assert.notNull(authorizationService, "authorizationService cannot be null");
        Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
        this.authenticationManager = authenticationManager;
        this.authorizationService = authorizationService;
        this.tokenGenerator = tokenGenerator;
    }
    
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        
//      PasswordAuthenticationToken
        OAuth2PasswordAuthenticationToken passwordAuthenticationToken = (OAuth2PasswordAuthenticationToken) authentication;
        
        // coverter中最后生成的token愤估,包含3部分內(nèi)容帮辟,在這里拿出來,下面用
        Map<String, Object> additionalParameters = passwordAuthenticationToken.getAdditionalParameters();
        OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient(passwordAuthenticationToken);
        RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
        
        // 1. 驗(yàn)證客戶端是否支持密碼模式類型(grant_type=password)
        if (!registeredClient.getAuthorizationGrantTypes().contains(passwordAuthenticationToken.getGrantType())) {
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE);
        }
        // 2. 校驗(yàn)范圍scope
        Set<String> authorizedScopes = registeredClient.getScopes();
        Set<String> requestedScopes  = passwordAuthenticationToken.getScopes();
        if (!CollectionUtils.isEmpty(requestedScopes )) {
            Set<String> unauthorizedScopes = requestedScopes.stream()
                    .filter(scope -> !registeredClient.getScopes().contains(scope))
                    .collect(Collectors.toSet());
            if (!CollectionUtils.isEmpty(unauthorizedScopes)) {
                throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);
            }
            authorizedScopes = new LinkedHashSet<>(requestedScopes);
        }
        // 3 用戶名密碼校驗(yàn)
        String username = (String) additionalParameters.get(OAuth2ParameterNames.USERNAME);
        String password = (String) additionalParameters.get(OAuth2ParameterNames.PASSWORD);
        // 我們不自己校驗(yàn)了玩焰,用oauth2的方法校驗(yàn)
//        MyUserDetail userDetail = userDetailsService.loadUserByUsername(username);
//        if (userDetail == null) {
//          throw new OAuth2AuthenticationException("用戶不存在由驹!");
//      }
//        if (!passwordEncoder.matches(password, userDetail.getPassword())) {
//            throw new OAuth2AuthenticationException("密碼不正確!");
//        }
       
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        Authentication usernamePasswordAuthentication = null;
        try {
            usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        } catch (AuthenticationException e) {
            e.printStackTrace();
            throw new OAuth2AuthenticationException("賬號(hào)或密碼錯(cuò)誤");
        }
        // 處理userDetails的時(shí)候昔园,我們沒有添加權(quán)限信息蔓榄,這拿不到數(shù)據(jù)
//        Collection<? extends GrantedAuthority> authorities = usernamePasswordAuthentication.getAuthorities();
//        for (GrantedAuthority authoriti : authorities) {
//      }
        
        // 4. 生成token
        // 4.1 填充token需要的上下文數(shù)據(jù),按照授權(quán)碼模式來的
        Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
                .registeredClient(registeredClient)
                // 身份驗(yàn)證成功的認(rèn)證信息(用戶名蒿赢、權(quán)限等信息)
                .principal(usernamePasswordAuthentication) 
                .authorizationServerContext(AuthorizationServerContextHolder.getContext())
                .authorizedScopes(authorizedScopes)
                // 授權(quán)類型
                .authorizationGrantType(passwordAuthenticationToken.getGrantType())
                // 授權(quán)具體對(duì)象
                .authorizationGrant(passwordAuthenticationToken)
                ;
        // 4.2 生成訪問令牌(Access Token)
        DefaultOAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build();
        OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
        if (generatedAccessToken == null) {
            OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
                    "The token generator failed to generate the access token.", ERROR_URI);
            throw new OAuth2AuthenticationException(error);
        }
        OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
                generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
                generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());

        // 4. 生成刷新令牌(Refresh Token)
        OAuth2RefreshToken refreshToken = null;
        if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) &&
                // Do not issue refresh token to public client
                !clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {
            tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();
            OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);
            if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) {
                OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
                        "The token generator failed to generate the refresh token.", ERROR_URI);
                throw new OAuth2AuthenticationException(error);
            }
            refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
        }
        
        
        // 5. 組裝數(shù)據(jù)入庫
        OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
                .principalName(clientPrincipal.getName())
                .authorizationGrantType(passwordAuthenticationToken.getGrantType())
                .authorizedScopes(authorizedScopes)
                .attribute(Principal.class.getName(), usernamePasswordAuthentication);
        if (generatedAccessToken instanceof ClaimAccessor) {
            authorizationBuilder.token(accessToken, (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));
        } else {
            authorizationBuilder.accessToken(accessToken);
            authorizationBuilder.refreshToken(refreshToken);
        }
        OAuth2Authorization authorization = authorizationBuilder.build();
        // 入庫
        this.authorizationService.save(authorization);
        additionalParameters = Collections.emptyMap();

        return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return OAuth2PasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}
1.3 passwordToken實(shí)體 - OAuth2PasswordAuthenticationToken
/**
 * 這個(gè)token就是傳遞數(shù)據(jù)用的一個(gè)實(shí)體润樱,想要什么數(shù)據(jù)可以寫成變量
 * 在converter生成時(shí)賦值,在provider中拿出來用
 */
public class OAuth2PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {
    
    private static final long serialVersionUID = -7029686994815546552L;

    public static final AuthorizationGrantType PASSWORD = new AuthorizationGrantType("password");
    
    private final Set<String> scopes;

    /**
     * 密碼模式身份驗(yàn)證令牌
     *
     * @param clientPrincipal      客戶端信息
     * @param scopes               令牌申請(qǐng)?jiān)L問范圍
     * @param additionalParameters 自定義額外參數(shù)(用戶名和密碼等)
     */
    protected OAuth2PasswordAuthenticationToken(Authentication clientPrincipal, @Nullable Set<String> scopes,
            @Nullable Map<String, Object> additionalParameters) {
        super(PASSWORD, clientPrincipal, additionalParameters);
        this.scopes = Collections.unmodifiableSet((scopes != null) ? new HashSet<>(scopes) : Collections.emptySet());
    }
    
    
    /**
     * 這個(gè)方法 父類中直接返回了空字符串羡棵,
     * 我們可以根據(jù)自己的需要重寫壹若,可以返回密碼
     */
    @Override
    public Object getCredentials() {
        return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD);
    }


    public Set<String> getScopes() {
        return this.scopes;
    }
}
1.4 util類

有2個(gè),

  1. OAuth2AuthenticationProviderUtils
public class OAuth2AuthenticationProviderUtils {
    
    private OAuth2AuthenticationProviderUtils() {
    }

    public static OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient(Authentication authentication) {
        OAuth2ClientAuthenticationToken clientPrincipal = null;
        if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) {
            clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal();
        }
        if (clientPrincipal != null && clientPrincipal.isAuthenticated()) {
            return clientPrincipal;
        }
        throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
    }

    public static <T extends OAuth2Token> OAuth2Authorization invalidate(OAuth2Authorization authorization, T token) {

        // @formatter:off
        OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization)
                .token(token,
                        (metadata) ->
                                metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true));

        if (OAuth2RefreshToken.class.isAssignableFrom(token.getClass())) {
            authorizationBuilder.token(
                    authorization.getAccessToken().getToken(),
                    (metadata) ->
                            metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true));

            OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =
                    authorization.getToken(OAuth2AuthorizationCode.class);
            if (authorizationCode != null && !authorizationCode.isInvalidated()) {
                authorizationBuilder.token(
                        authorizationCode.getToken(),
                        (metadata) ->
                                metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true));
            }
        }
        // @formatter:on

        return authorizationBuilder.build();
    }

    public static <T extends OAuth2Token> OAuth2AccessToken accessToken(OAuth2Authorization.Builder builder, T token,
            OAuth2TokenContext accessTokenContext) {

        OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token.getTokenValue(),
                token.getIssuedAt(), token.getExpiresAt(), accessTokenContext.getAuthorizedScopes());
        OAuth2TokenFormat accessTokenFormat = accessTokenContext.getRegisteredClient()
            .getTokenSettings()
            .getAccessTokenFormat();
        builder.token(accessToken, (metadata) -> {
            if (token instanceof ClaimAccessor claimAccessor) {
                metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claimAccessor.getClaims());
            }
            metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, false);
            metadata.put(OAuth2TokenFormat.class.getName(), accessTokenFormat.getValue());
        });

        return accessToken;
    }
}
  1. OAuth2EndpointUtils
public class OAuth2EndpointUtils {
    
    public static final String ACCESS_TOKEN_REQUEST_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";

    private OAuth2EndpointUtils() {
    }

    public static MultiValueMap<String, String> getFormParameters(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameterMap.forEach((key, values) -> {
            String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : "";
            // If not query parameter then it's a form parameter
            if (!queryString.contains(key) && values.length > 0) {
                for (String value : values) {
                    parameters.add(key, value);
                }
            }
        });
        return parameters;
    }

    public static MultiValueMap<String, String> getQueryParameters(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameterMap.forEach((key, values) -> {
            String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : "";
            if (queryString.contains(key) && values.length > 0) {
                for (String value : values) {
                    parameters.add(key, value);
                }
            }
        });
        return parameters;
    }

    public static Map<String, Object> getParametersIfMatchesAuthorizationCodeGrantRequest(HttpServletRequest request,
            String... exclusions) {
        if (!matchesAuthorizationCodeGrantRequest(request)) {
            return Collections.emptyMap();
        }
        MultiValueMap<String, String> multiValueParameters = "GET".equals(request.getMethod())
                ? getQueryParameters(request) : getFormParameters(request);
        for (String exclusion : exclusions) {
            multiValueParameters.remove(exclusion);
        }

        Map<String, Object> parameters = new HashMap<>();
        multiValueParameters.forEach(
                (key, value) -> parameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])));

        return parameters;
    }

    public static boolean matchesAuthorizationCodeGrantRequest(HttpServletRequest request) {
        return AuthorizationGrantType.AUTHORIZATION_CODE.getValue()
            .equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE))
                && request.getParameter(OAuth2ParameterNames.CODE) != null;
    }

    public static boolean matchesPkceTokenRequest(HttpServletRequest request) {
        return matchesAuthorizationCodeGrantRequest(request)
                && request.getParameter(PkceParameterNames.CODE_VERIFIER) != null;
    }

    public static void throwError(String errorCode, String parameterName, String errorUri) {
        OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri);
        throw new OAuth2AuthenticationException(error);
    }

    public static String normalizeUserCode(String userCode) {
        Assert.hasText(userCode, "userCode cannot be empty");
        StringBuilder sb = new StringBuilder(userCode.toUpperCase().replaceAll("[^A-Z\\d]+", ""));
        Assert.isTrue(sb.length() == 8, "userCode must be exactly 8 alpha/numeric characters");
        sb.insert(4, '-');
        return sb.toString();
    }

    public static boolean validateUserCode(String userCode) {
        return (userCode != null && userCode.toUpperCase().replaceAll("[^A-Z\\d]+", "").length() == 8);
    }
}

這樣就是添加完了皂冰。


image.png

2. 認(rèn)證服務(wù)器配置

認(rèn)證服務(wù)器1是需要驗(yàn)證賬號(hào)密碼店展,2是需要生成Token。驗(yàn)證賬號(hào)密碼還是Springsecurity的邏輯秃流,生成token用的是jwt赂蕴。所以需要配置2方面。
首先我們要把數(shù)據(jù)庫導(dǎo)進(jìn)去舶胀,在maven導(dǎo)入的包里面概说,自己可以找到:


sql

image.png
2.1 security配置

和以前說的 spring security配置差不多,要userdetail嚣伐、WebSecutiryConfig糖赔、錯(cuò)誤返回?cái)?shù)據(jù)類等。

2.1.1 userdetail
@Slf4j
public class UserDetailService implements UserDetailsService {
    
    @Resource
    private ResourceService resourceService;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Result<AuthUser> result = resourceService.loadUserByName(username);
        if(!CodeMsg.SUCCESS.getCode().equals(result.getCode())) {
            throw new UsernameNotFoundException("賬號(hào)或密碼錯(cuò)誤轩端!");
        }
        AuthUser user = BeanUtil.toBean(result.getData(), AuthUser.class) ;
        MyUserDetail userDetail = new MyUserDetail();
        BeanUtil.copyProperties(user, userDetail, false);
        if (!userDetail.isEnabled()) {
            throw new DisabledException("賬號(hào)狀態(tài)異常放典!");
        }
        
        return userDetail;
    }
}
@Data
@EqualsAndHashCode(callSuper = false)
public class MyUserDetail extends AuthUser implements UserDetails {

    private static final long serialVersionUID = 786868339462173799L;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        
        return null;
    }

    @Override
    public String getUsername() {
        return this.getUname();
    }

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return this.getStatus() == 1;
    }
}
2.1.2 WebSecutiryConfig
@Configuration
public class WebSecutiryConfig {


    @Bean
    UserDetailsService userDetailsService() {
        UserDetailService userDetail = new UserDetailService();
        return userDetail;
    }


    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
     /**
     * Spring Security 安全過濾器鏈配置
     */
    @Bean
    @Order(1)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        
        // 1. 開啟認(rèn)證,提前排除 不需要認(rèn)證的
        http.authorizeHttpRequests(authorize -> {
            authorize
                    // 路徑不用加context-path
                    .requestMatchers("/oauth2/**").permitAll()
                    // 登陸圖形驗(yàn)證碼
                    .requestMatchers("/captcha/**").permitAll()
                    // 登陸
                    .requestMatchers("/login/oauthlogin").permitAll()
                    // admin
                    .requestMatchers("/actuator/**").permitAll()
                    .requestMatchers("/instances/**").permitAll()
                    .anyRequest().authenticated();
                })
            
        
        
        ;
        // 2. 登陸方式:默認(rèn)是使用security提供表單的登陸頁面和方式基茵,我們這里關(guān)閉
        http.formLogin(Customizer.withDefaults());
//      http.formLogin(AbstractHttpConfigurer::disable);
        
        // 3. 登出配置
//      http.logout(logout -> logout.logoutSuccessHandler(new MyLogoutSuccessHandler()));
        
        // 4. security異常錯(cuò)誤配置
        http.exceptionHandling(exception -> {
            // 未認(rèn)證時(shí) 訪問接口奋构,返回錯(cuò)誤
            exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());
            // 未授權(quán)時(shí),返回錯(cuò)誤拱层,一般資源服務(wù)器 才會(huì)用到這個(gè)
            exception.accessDeniedHandler(new MyAccessDeniedHandler());
        });
        
        
        
        
        
        // 5. csrf是默認(rèn)開啟的弥臼,此時(shí)對(duì)于post請(qǐng)求,會(huì)需要一個(gè)"_csrf"的隱藏字段傳遞舱呻,為了前端方便醋火,這個(gè)關(guān)了
        http.csrf(csrf -> csrf.disable());
        
        // 6. 跨域處理
        http.cors(Customizer.withDefaults());
        
        // 7. session管理悠汽,設(shè)置同一個(gè)賬號(hào)只能登陸一次.單體服務(wù)這個(gè)管用,微服務(wù) 不能用
//      http.sessionManagement(session -> session.maximumSessions(1).expiredSessionStrategy(new MySessionInformationExpiredStrategy()));

        return http.build();
    }

    /**
     * Spring Security 排除路徑
     */
    @Bean
    WebSecurityCustomizer webSecurityCustomizer() {
        return (web) ->
                // 不走過濾器鏈(swagger和靜態(tài)資源js芥驳、css柿冲、html)
                web.ignoring().requestMatchers(
                        "/webjars/**",
                        "/doc.html",
                        "/swagger-resources/**",
                        "/v3/api-docs/**",
                        "/swagger-ui/**"
                );
    }
}
2.1.3 返回?cái)?shù)據(jù)處理
  • MyAuthenticationEntryPoint
/**
 * 未認(rèn)證(沒有登錄)時(shí),返回異常 信息
 */
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final HttpMessageConverter<Object> httpResponseConverter = new MappingJackson2HttpMessageConverter();
    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException, ServletException {
        if (authException instanceof InvalidBearerTokenException) {
            System.out.println(authException.getMessage());
        }
        authException.printStackTrace();
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
        
        Result<String> res = new Result<String>().error(CodeMsg.AUTHENTICATION_FAILED);
        
        httpResponseConverter.write(res, null, httpResponse);

    }
}
  • MyAccessDeniedHandler
/**
 * 登陸了兆旬,沒有權(quán)限時(shí)假抄,觸發(fā)異常 返回信息
 */
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    
    private final HttpMessageConverter<Object> httpResponseConverter = new MappingJackson2HttpMessageConverter();

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        System.out.println("=====無權(quán)限的異常處理");
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
        
        Result<Integer> res = new Result<Integer>().error(accessDeniedException.getLocalizedMessage());
        
        
        httpResponseConverter.write(res, null, httpResponse);

    }
}

security的配置好了,目錄這樣的:


security目錄
2.2 oauth2配置

oauth2.1 主要配置了3個(gè)方面丽猬,都在AuthorizationServerConfig中:

    1. 認(rèn)證服務(wù)器使用自己寫的密碼模式
    1. token生成:公鑰宿饱、私鑰、自定義字段等
    1. token和數(shù)據(jù)庫交互
2.2.1 AuthorizationServerConfig
@Configuration
public class AuthorizationServerConfig {
    
    Logger log = LoggerFactory.getLogger(this.getClass()); 
    
    private static final String KEY_ID = "jnGZxjHC54hP4ZnXrrEedtNweQ6aK29w";
    
    @Resource
    private MyOAuth2TokenJwtCustomizer jwtCustomizer;
    @Resource
    private RSAKeyPair rsaKeyPair;

    /**
     * 授權(quán)服務(wù)器端點(diǎn)配置
     */
    @Bean
    @Order(1)
    SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,
                                                            AuthenticationManager authenticationManager,
                                                            OAuth2AuthorizationService authorizationService,
                                                            OAuth2TokenGenerator<?> tokenGenerator) throws Exception {
        // 這是 http 的默認(rèn)配置脚祟,可以點(diǎn)進(jìn)去看一下谬以,我們?yōu)榱思尤胱约旱拿艽a模式,需要把
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
//      OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
//        RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
//        http.securityMatcher(endpointsMatcher)
//                .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
//                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
//                .apply(authorizationServerConfigurer);
        
        // 添加password模式
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).tokenEndpoint(tokenEndpoint ->
                    tokenEndpoint
                        // 添加授權(quán)模式轉(zhuǎn)換器(Converter)
                        .accessTokenRequestConverter(new OAuth2PasswordAuthenticationConverter())
                        // 添加 授權(quán)模式提供者(Provider)
                        .authenticationProvider(new OAuth2PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator))
                        // 成功響應(yīng)
                        .accessTokenResponseHandler(new MyAuthenticationSuccessHandler())
                        // 失敗響應(yīng)
                        .errorResponseHandler(new MyAuthenticationFailureHandler()));
        
        // 開啟OpenID Connect 1.0協(xié)議相關(guān)端點(diǎn)
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
        
        // 當(dāng)使用授權(quán)碼模式時(shí)由桌,因授權(quán)碼模式需要登陸为黎,這個(gè)配置需要打開
//      http.exceptionHandling(exception -> exception
//              .defaultAuthenticationEntryPointFor(
//                  new LoginUrlAuthenticationEntryPoint("/login"),
//                  new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
//              )
//          );
        return http.build();
    }


    /**
     * 配置認(rèn)證服務(wù)器請(qǐng)求地址
     */
    @Bean
    @Order(2)
    AuthorizationServerSettings authorizationServerSettings() {
        // 什么都不配置,則使用默認(rèn)地址   
        return AuthorizationServerSettings.builder().build();
    }
    
    
    // 客戶端信息表:oauth2_registered_client
    @Bean
    @Order(3)
    RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        return registeredClientRepository;
    }


    // 和 token表交互數(shù)據(jù)用的:oauth2_authorization
    @Bean
    OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {

        JdbcOAuth2AuthorizationService service = new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
        JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper = new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(registeredClientRepository);
        rowMapper.setLobHandler(new DefaultLobHandler());
        ObjectMapper objectMapper = new ObjectMapper();
        ClassLoader classLoader = JdbcOAuth2AuthorizationService.class.getClassLoader();
        List<Module> modules = SecurityJackson2Modules.getModules(classLoader);
        objectMapper.registerModules(modules);
        objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
        // 使用刷新模式行您,需要從 oauth2_authorization 表反序列化attributes字段得到用戶信息(SysUserDetails)
//        objectMapper.addMixIn(SysUserDetails.class, SysUserMixin.class);
        objectMapper.addMixIn(Long.class, Object.class);
        
        rowMapper.setObjectMapper(objectMapper);
        service.setAuthorizationRowMapper(rowMapper);
        return service;
    }

    // 和授權(quán)記錄表交互數(shù)據(jù)用的:oauth2_authorization_consent
    @Bean
    OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }


    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }


    /**
     * =========   Token部分  ========
     */
    
    /**
     * 配置 JWK铭乾,為JWT(id_token)提供加密密鑰,用于加密/解密或簽名/驗(yàn)簽
     * JWK詳細(xì)見:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
     */
    @Bean
    JWKSource<SecurityContext> jwkSource() {
        RSAPublicKey publicKey = null;
        RSAPrivateKey privateKey = null;
        String publicKeyBase64 = rsaKeyPair.getPublicKeyBase64();
        String privateKeyBase64 = rsaKeyPair.getPrivateKeyBase64();
        if (StringUtils.hasText(publicKeyBase64) && StringUtils.hasText(privateKeyBase64)) {
            publicKey = getPublicKey(publicKeyBase64);
            privateKey = getPrivateKey(privateKeyBase64);
        } else {
            KeyPair keyPair = generateRsaKey();
            publicKey = (RSAPublicKey) keyPair.getPublic();
            privateKey = (RSAPrivateKey) keyPair.getPrivate();
            log.warn("未設(shè)置生成token的秘鑰M扪?婚荨!");
            log.info("生成臨時(shí)秘鑰:");
            log.info("\r\n===publicKey===" + 
                    "\r\n" + 
                    Base64.getEncoder().encodeToString(publicKey.getEncoded()) + 
                    "\r\n===privateKey===" + 
                    "\r\n" + 
                    Base64.getEncoder().encodeToString(privateKey.getEncoded()));
        }
        
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(KEY_ID)
                .build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }
    
    private RSAPublicKey getPublicKey(String publicKeyBase64) {
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyBase64));
        RSAPublicKey rsaPublicKey = null;
        try {
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            rsaPublicKey = (RSAPublicKey)keyFactory.generatePublic(keySpec);
        } catch (Exception e) {
            e.printStackTrace();
        }
        
        return rsaPublicKey;
    }
    
    private RSAPrivateKey getPrivateKey(String privateKeyBase64) {
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64));
        RSAPrivateKey rsaPrivateKey = null;
        try {
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            rsaPrivateKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec);
        } catch (Exception e) {
            e.printStackTrace();
        }
        
        return rsaPrivateKey;
    }
    
    /**
     *  生成RSA密鑰對(duì)捌斧,給上面jwkSource() 方法的提供密鑰對(duì)
     */
    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解析器
     */
    @Bean
    JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }
    
    /**
     * 配置token生成器
     */
    @Bean
    OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
        JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
        // token 中加入自定義的字段內(nèi)容
        jwtGenerator.setJwtCustomizer(jwtCustomizer);

        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
    }
}
2.2.2 自定義token用到的類
  • MyOAuth2TokenJwtCustomizer
/**
 * 自定義Token包含的字段信息笛质,
 * 通過context拿到authentication和其他信息,然后再拿到Principal(userDetails數(shù)據(jù))或者其他
 */
@Configuration
public class MyOAuth2TokenJwtCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    @Override
    public void customize(JwtEncodingContext context) {
        JwtClaimsSet.Builder claims = context.getClaims();
        System.out.println("自定義token字段");
        Authentication authentication = context.getPrincipal();
        MyUserDetail detail = (MyUserDetail) authentication.getPrincipal();
        claims.claim("uid", detail.getUid());
        claims.claim("rcodes", detail.getRcodes());
    }
}
  • RSAKeyPair
@Component
public class RSAKeyPair {
    
    @Value("${security.token.public_key_base64:null}")
    private String publicKeyBase64;
    
    @Value("${security.token.private_key_base64:null}")
    private String privateKeyBase64;

    public String getPublicKeyBase64() {
        return publicKeyBase64;
    }

    public String getPrivateKeyBase64() {
        return privateKeyBase64;
    }
}
2.2.3 自定義返回?cái)?shù)據(jù)類
  • MyAuthenticationFailureHandler
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    
    private final HttpMessageConverter<Object> httpResponseConverter = new MappingJackson2HttpMessageConverter();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
        Result<String> res = new Result<String>();
        if (exception instanceof UsernameNotFoundException) {
            res.error(CodeMsg.USERNAME_OR_PASSWORD_ERROR);
        } else if (exception instanceof OAuth2AuthenticationException) {
            OAuth2Error error = ((OAuth2AuthenticationException) exception).getError();
            res.error(error.getErrorCode());
        }
        
        System.out.println("error走這個(gè)了: MyAuthenticationFailureHandler");
        
        httpResponseConverter.write(res, null, httpResponse);
    }

}
  • MyAuthenticationSuccessHandler
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    
    /**
     * MappingJackson2HttpMessageConverter 是 Spring 框架提供的一個(gè) HTTP 消息轉(zhuǎn)換器捞蚂,用于將 HTTP 請(qǐng)求和響應(yīng)的 JSON 數(shù)據(jù)與 Java 對(duì)象之間進(jìn)行轉(zhuǎn)換
     */
    private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();
    private Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) authentication;
        
        OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken();
        OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken();
        Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters();

        OAuth2AccessTokenResponse.Builder builder =OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue()).tokenType(accessToken.getTokenType());
        if (accessToken.getIssuedAt() != null && accessToken.getExpiresAt() != null) {
            builder.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));
        }
        if (refreshToken != null) {
            builder.refreshToken(refreshToken.getTokenValue());
        }
        if (!CollectionUtils.isEmpty(additionalParameters)) {
            builder.additionalParameters(additionalParameters);
        }
        OAuth2AccessTokenResponse accessTokenResponse = builder.build();

        Map<String, Object> tokenResponseParameters = accessTokenResponseParametersConverter.convert(accessTokenResponse);
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
        
        
        this.accessTokenHttpResponseConverter.write(new Result<String>().success(tokenResponseParameters), null, httpResponse);

    }
}

密碼擴(kuò)展基本完成了经瓷,整個(gè)結(jié)構(gòu)是這樣的:


image.png
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市洞难,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌揭朝,老刑警劉巖队贱,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異潭袱,居然都是意外死亡柱嫌,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門屯换,熙熙樓的掌柜王于貴愁眉苦臉地迎上來编丘,“玉大人与学,你說我怎么就攤上這事〖巫ィ” “怎么了索守?”我有些...
    開封第一講書人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)抑片。 經(jīng)常有香客問我卵佛,道長(zhǎng),這世上最難降的妖魔是什么敞斋? 我笑而不...
    開封第一講書人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任截汪,我火速辦了婚禮,結(jié)果婚禮上植捎,老公的妹妹穿的比我還像新娘衙解。我一直安慰自己,他們只是感情好焰枢,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開白布蚓峦。 她就那樣靜靜地躺著,像睡著了一般医咨。 火紅的嫁衣襯著肌膚如雪枫匾。 梳的紋絲不亂的頭發(fā)上要门,一...
    開封第一講書人閱讀 49,144評(píng)論 1 285
  • 那天执虹,我揣著相機(jī)與錄音,去河邊找鬼朴下。 笑死很泊,一個(gè)胖子當(dāng)著我的面吹牛角虫,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播委造,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼戳鹅,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了昏兆?” 一聲冷哼從身側(cè)響起枫虏,我...
    開封第一講書人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎爬虱,沒想到半個(gè)月后隶债,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡跑筝,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年死讹,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片曲梗。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡赞警,死狀恐怖妓忍,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情愧旦,我是刑警寧澤世剖,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布,位于F島的核電站忘瓦,受9級(jí)特大地震影響搁廓,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜耕皮,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一境蜕、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧凌停,春花似錦粱年、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至赐俗,卻和暖如春拉队,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背阻逮。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來泰國打工粱快, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人叔扼。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓事哭,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國和親瓜富。 傳聞我的和親對(duì)象是個(gè)殘疾皇子鳍咱,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容