Spring REST API + OAuth2 + AngularJS

http://www.baeldung.com/rest-api-spring-oauth2-angularjs
作者:Eugen Paraschiv
譯者http://oopsguy.com

1、概述

在本教程中,我們將使用 OAuth 來保護(hù) REST API,并通過一個簡單的 AngularJS 客戶端進(jìn)行示范。

我們要建立的應(yīng)用將包含了四個獨(dú)立模塊:

  • 授權(quán)服務(wù)器
  • 資源服務(wù)器
  • UI implicit —— 一個使用 Implicit Flow 的前端應(yīng)用
  • UI password —— 一個使用 Password Flow 的前端應(yīng)用

2、授權(quán)服務(wù)器

首先,讓我們先搭建一個簡單的 Spring Boot 應(yīng)用作為授權(quán)服務(wù)器。

2.1、Maven 配置

添加以下依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>    
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>  
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <version>${oauth.version}</version>
</dependency>

上面使用了 spring-jdbc 和 MySQL,因為我們將使用 JDBC 來實(shí)現(xiàn) token 存儲。

2.2、@EnableAuthorizationServer

現(xiàn)在,我們來配置負(fù)責(zé)管理 Access Token(訪問令牌)的授權(quán)服務(wù)器:

@Configuration
@EnableAuthorizationServer
public class AuthServerOAuth2Config
  extends AuthorizationServerConfigurerAdapter {
  
    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager;
 
    @Override
    public void configure(
      AuthorizationServerSecurityConfigurer oauthServer) 
      throws Exception {
        oauthServer
          .tokenKeyAccess("permitAll()")
          .checkTokenAccess("isAuthenticated()");
    }
 
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) 
      throws Exception {
        clients.jdbc(dataSource())
          .withClient("sampleClientId")
          .authorizedGrantTypes("implicit")
          .scopes("read")
          .autoApprove(true)
          .and()
          .withClient("clientIdPassword")
          .secret("secret")
          .authorizedGrantTypes(
            "password","authorization_code", "refresh_token")
          .scopes("read");
    }
 
    @Override
    public void configure(
      AuthorizationServerEndpointsConfigurer endpoints) 
      throws Exception {
  
        endpoints
          .tokenStore(tokenStore())
          .authenticationManager(authenticationManager);
    }
 
    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource());
    }
}

注意:

  • 為了持久化 token,我們使用了一個 JdbcTokenStore
  • 我們?yōu)?implicit 授權(quán)類型注冊了一個客戶端
  • 我們注冊了另一個客戶端,授權(quán)了 passwordauthorization_coderefresh_token 等授權(quán)類型
  • 為了使用 password 授權(quán)類型,我們需要裝配并使用 AuthenticationManager bean

2.3、數(shù)據(jù)源配置

接下來,讓我們?yōu)?JdbcTokenStore 配置數(shù)據(jù)源:

@Value("classpath:schema.sql")
private Resource schemaScript;
 
@Bean
public DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
    DataSourceInitializer initializer = new DataSourceInitializer();
    initializer.setDataSource(dataSource);
    initializer.setDatabasePopulator(databasePopulator());
    return initializer;
}
 
private DatabasePopulator databasePopulator() {
    ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
    populator.addScript(schemaScript);
    return populator;
}
 
@Bean
public DataSource dataSource() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName(env.getProperty("jdbc.driverClassName"));
    dataSource.setUrl(env.getProperty("jdbc.url"));
    dataSource.setUsername(env.getProperty("jdbc.user"));
    dataSource.setPassword(env.getProperty("jdbc.pass"));
    return dataSource;
}

請注意,由于我們使用了 JdbcTokenStore,需要初始化數(shù)據(jù)庫 schema(模式),因此我們使用了 DataSourceInitializer,和以下 SQL schema:

drop table if exists oauth_client_details;
create table oauth_client_details (
  client_id VARCHAR(255) PRIMARY KEY,
  resource_ids VARCHAR(255),
  client_secret VARCHAR(255),
  scope VARCHAR(255),
  authorized_grant_types VARCHAR(255),
  web_server_redirect_uri VARCHAR(255),
  authorities VARCHAR(255),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(255)
);
 
drop table if exists oauth_client_token;
create table oauth_client_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication_id VARCHAR(255) PRIMARY KEY,
  user_name VARCHAR(255),
  client_id VARCHAR(255)
);
 
drop table if exists oauth_access_token;
create table oauth_access_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication_id VARCHAR(255) PRIMARY KEY,
  user_name VARCHAR(255),
  client_id VARCHAR(255),
  authentication LONG VARBINARY,
  refresh_token VARCHAR(255)
);
 
drop table if exists oauth_refresh_token;
create table oauth_refresh_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication LONG VARBINARY
);
 
drop table if exists oauth_code;
create table oauth_code (
  code VARCHAR(255), authentication LONG VARBINARY
);
 
drop table if exists oauth_approvals;
create table oauth_approvals (
    userId VARCHAR(255),
    clientId VARCHAR(255),
    scope VARCHAR(255),
    status VARCHAR(10),
    expiresAt TIMESTAMP,
    lastModifiedAt TIMESTAMP
);
 
drop table if exists ClientDetails;
create table ClientDetails (
  appId VARCHAR(255) PRIMARY KEY,
  resourceIds VARCHAR(255),
  appSecret VARCHAR(255),
  scope VARCHAR(255),
  grantTypes VARCHAR(255),
  redirectUrl VARCHAR(255),
  authorities VARCHAR(255),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additionalInformation VARCHAR(4096),
  autoApproveScopes VARCHAR(255)
);

需要注意的是,我們不一定需要顯式聲明 DatabasePopulator bean —— 我們可以簡單地使用一個 schema.sql —— Spring Boot 默認(rèn)

2.4、安全配置

最后,讓我們將授權(quán)服務(wù)器變得更加安全。

當(dāng)客戶端應(yīng)用需要獲取一個 Access Token 時,在一個簡單的表單登錄驅(qū)動驗證處理之后,它將執(zhí)行此操作:

@Configuration
public class ServerSecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Override
    protected void configure(AuthenticationManagerBuilder auth) 
      throws Exception {
        auth.inMemoryAuthentication()
          .withUser("john").password("123").roles("USER");
    }
 
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() 
      throws Exception {
        return super.authenticationManagerBean();
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/login").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin().permitAll();
    }
}

這里的需要提及的是,Password flow 不需要表單登錄配置 —— 僅限于 Implicit flow,因此你可以根據(jù)你使用的 OAuth2 flow 跳過它。

3、資源服務(wù)器

現(xiàn)在,我們來討論一下資源服務(wù)器;本質(zhì)上就是我們想要消費(fèi)的 REST API。

3.1、Maven 配置

我們的資源服務(wù)器配置與之前的授權(quán)服務(wù)器應(yīng)用配置相同。

3.2、Token 存儲配置

接下來,我們將配置 TokenStore 來訪問與授權(quán)服務(wù)器用于存儲 Access Token 相同的數(shù)據(jù)庫:

@Autowired
private Environment env;
 
@Bean
public DataSource dataSource() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName(env.getProperty("jdbc.driverClassName"));
    dataSource.setUrl(env.getProperty("jdbc.url"));
    dataSource.setUsername(env.getProperty("jdbc.user"));
    dataSource.setPassword(env.getProperty("jdbc.pass"));
    return dataSource;
}
 
@Bean
public TokenStore tokenStore() {
    return new JdbcTokenStore(dataSource());
}

請注意,針對這個簡單的實(shí)現(xiàn),即使授權(quán)服務(wù)器與資源服務(wù)器是單獨(dú)的應(yīng)用,我們也共享著 token 存儲的 SQL

原因當(dāng)然是資源服務(wù)器需要能夠驗證授權(quán)服務(wù)器發(fā)出的 Access Token 的有效性。

3.3、遠(yuǎn)程 Token 服務(wù)

我們需要使用 RemoteTokeServices,而不是 TokenStore

@Primary
@Bean
public RemoteTokenServices tokenService() {
    RemoteTokenServices tokenService = new RemoteTokenServices();
    tokenService.setCheckTokenEndpointUrl(
      "http://localhost:8080/spring-security-oauth-server/oauth/check_token");
    tokenService.setClientId("fooClientIdPassword");
    tokenService.setClientSecret("secret");
    return tokenService;
}

注意:

  • RemoteTokenService 將使用授權(quán)服務(wù)器上的 CheckTokenEndPoint 來驗證 AccessToken 并從中獲取 Authentication 對象。
  • 可以在 AuthorizationServerBaseURL + /oauth/check_token 找到
  • 授權(quán)服務(wù)器可以使用任何 TokenStore 類型 [JdbcTokenStoreJwtTokenStore、……] —— 這不會影響到 RemoteTokenService 或者資源服務(wù)器。

3.4、一個簡單的控制器

接下來實(shí)現(xiàn)一個簡單控制器以暴露一個 Foo 資源:

@Controller
public class FooController {
 
    @PreAuthorize("#oauth2.hasScope('read')")
    @RequestMapping(method = RequestMethod.GET, value = "/foos/{id}")
    @ResponseBody
    public Foo findById(@PathVariable long id) {
        return
          new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4));
    }
}

請注意客戶端需要 read scope(范圍、作用域或權(quán)限)訪問此資源。

我們還需要開啟全局方法保護(hù)并配置 MethodSecurityExpressionHandler

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OAuth2ResourceServerConfig 
  extends GlobalMethodSecurityConfiguration {
 
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        return new OAuth2MethodSecurityExpressionHandler();
    }
}

以下是基礎(chǔ)的 Foo 資源:

public class Foo {
    private long id;
    private String name;
}

3.5、Web 配置

最后,為 API 設(shè)置一個非常基本的 Web 配置:

@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller" })
public class ResourceWebConfig extends WebMvcConfigurerAdapter {}

4、前端 - Password Flow

來看看一個簡單的前端 AngularJS 客戶端實(shí)現(xiàn)。

我們將在這里使用 OAuth2 Password flow —— 這就是為什么這只是一個示例,而不是一個可用于生產(chǎn)的應(yīng)用。你會注意到,客戶端憑據(jù)被暴露在前端 —— 這也是我們將來在以后的文章中要討論的。

我們從兩個簡單的頁面開始 - “index” 和 “l(fā)ogin”;一旦用戶提供憑據(jù),前端 JS 客戶端將使用它們從授權(quán)服務(wù)器獲取的一個 Access Token。

4.1、登錄頁面

以下是一個簡單的登錄頁面:

<body ng-app="myApp" ng-controller="mainCtrl">
<h1>Login</h1>
<label>Username</label><input ng-model="data.username"/>
<label>Password</label><input type="password" ng-model="data.password"/>
<a href="#" ng-click="login()">Login</a>
</body>

4.2、獲取 Access Token

現(xiàn)在,讓我們來看看如何獲取 Access Token:

var app = angular.module('myApp', ["ngResource","ngRoute","ngCookies"]);
app.controller('mainCtrl', 
  function($scope, $resource, $http, $httpParamSerializer, $cookies) {
     
    $scope.data = {
        grant_type:"password", 
        username: "", 
        password: "", 
        client_id: "clientIdPassword"
    };
    $scope.encoded = btoa("clientIdPassword:secret");
     
    $scope.login = function() {   
        var req = {
            method: 'POST',
            url: "http://localhost:8080/spring-security-oauth-server/oauth/token",
            headers: {
                "Authorization": "Basic " + $scope.encoded,
                "Content-type": "application/x-www-form-urlencoded; charset=utf-8"
            },
            data: $httpParamSerializer($scope.data)
        }
        $http(req).then(function(data){
            $http.defaults.headers.common.Authorization = 
              'Bearer ' + data.data.access_token;
            $cookies.put("access_token", data.data.access_token);
            window.location.href="index";
        });   
   }    
});

注意:

  • 我們發(fā)送一個 POST 到 /oauth/token 端點(diǎn)以獲取一個 Access Token
  • 我們使用客戶端憑據(jù)和 Basic Auth 驗證來訪問此端點(diǎn)
  • 之后我們發(fā)送用戶憑證以及客戶端 id 和授權(quán)類型參數(shù)的 URL 編碼
  • 獲取 Access Token 后,我們將其存儲在一個 cookie 中

cookie 存儲在這里特別重要,因為我們只使用 cookie 作為存儲目標(biāo),而不是直接發(fā)動身份驗證過程。這有助于防止跨站點(diǎn)請求偽造(CSRF)類型的攻擊和漏洞

4.3、主頁面

以下是一個簡單的主頁面:

<body ng-app="myApp" ng-controller="mainCtrl">
<h1>Foo Details</h1>
<label>ID</label><span>{{foo.id}}</span>
<label>Name</label><span>{{foo.name}}</span>
<a href="#" ng-click="getFoo()">New Foo</a>
</body>

4.4、授權(quán)客戶端請求

由于我們需要 Access Token 為對資源的請求進(jìn)行授權(quán),我們將追加一個帶有 Access Token 的簡單授權(quán)頭:

var isLoginPage = window.location.href.indexOf("login") != -1;
if(isLoginPage){
    if($cookies.get("access_token")){
        window.location.href = "index";
    }
} else{
    if($cookies.get("access_token")){
        $http.defaults.headers.common.Authorization = 
          'Bearer ' + $cookies.get("access_token");
    } else{
        window.location.href = "login";
    }
}

沒有找到 cookie,用戶將跳轉(zhuǎn)到登錄頁面。

5.前端 —— 隱式授權(quán)(Implicit Grant)

現(xiàn)在,我們來看看使用了隱式授權(quán)的客戶端應(yīng)用。

我們的客戶端應(yīng)用是一個獨(dú)立的模塊,嘗試使用隱式授權(quán)流程從授權(quán)服務(wù)器獲取 Access Token 后訪問資源服務(wù)器。

5.1、Maven 配置

這里是 pom.xml 依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

注意:我們不需要 OAuth 依賴,因為我們將使用 AngularJS 的 OAuth-ng 指令來處理,其可以使用隱式授權(quán)流程連接到 OAuth2 服務(wù)器。

5.2、Web 配置

以下是我們的一個簡單的 Web 配置:

@Configuration
@EnableWebMvc
public class UiWebConfig extends WebMvcConfigurerAdapter {
    @Bean
    public static PropertySourcesPlaceholderConfigurer 
      propertySourcesPlaceholderConfigurer() {
        return new PropertySourcesPlaceholderConfigurer();
    }
 
    @Override
    public void configureDefaultServletHandling(
      DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }
 
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        super.addViewControllers(registry);
        registry.addViewController("/index");
        registry.addViewController("/oauthTemplate");
    }
 
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**")
          .addResourceLocations("/resources/");
    }
}

5.3、主頁

接下來,這里是我們的主頁:

OAuth-ng 指令需要:

  • site:授權(quán)服務(wù)器 URL
  • client-id:應(yīng)用客戶端 id
  • redirect-uri:從授權(quán)服務(wù)器獲 Access Token 后,要重定向到的 URI
  • scope:從授權(quán)服務(wù)器請求的權(quán)限
  • template:渲染自定義 HTML 模板
<body ng-app="myApp" ng-controller="mainCtrl">
    <oauth
      site="http://localhost:8080/spring-security-oauth-server"
      client-id="clientId"
      redirect-uri="http://localhost:8080/spring-security-oauth-ui-implicit/index"
      scope="read"
      template="oauthTemplate">
    </oauth>
 
<h1>Foo Details</h1>
<label >ID</label><span>{{foo.id}}</span>
<label>Name</label><span>{{foo.name}}</span>
</div>
<a href="#" ng-click="getFoo()">New Foo</a>
 
<script
  src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular.min.js">
</script>
<script
  src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-resource.min.js">
</script>
<script
  src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.14/angular-route.min.js">
</script>
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/ngStorage/0.3.9/ngStorage.min.js">
</script>
<script th:src="@{/resources/oauth-ng.js}"></script>
</body>

請注意我們?nèi)绾问褂?OAuth-ng 指令來獲取 Access Token。

另外,以下是一個簡單的 oauthTemplate.html

<div>
  <a href="#" ng-show="show=='logged-out'" ng-click="login()">Login</a>
  <a href="#" ng-show="show=='denied'" ng-click="login()">Access denied. Try again.</a>
</div>

5.4、AngularJS App

這是我們的 AngularJS app:

var app = angular.module('myApp', ["ngResource","ngRoute","oauth"]);
app.config(function($locationProvider) {
  $locationProvider.html5Mode({
      enabled: true,
      requireBase: false
    }).hashPrefix('!');
});
 
app.controller('mainCtrl', function($scope,$resource,$http) {
    $scope.$on('oauth:login', function(event, token) {
        $http.defaults.headers.common.Authorization= 'Bearer ' + token.access_token;
    });
 
    $scope.foo = {id:0 , name:"sample foo"};
    $scope.foos = $resource(
      "http://localhost:8080/spring-security-oauth-resource/foos/:fooId", 
      {fooId:'@id'});
    $scope.getFoo = function(){
        $scope.foo = $scope.foos.get({fooId:$scope.foo.id});
    } 
});

請注意,在獲取 Access Token 后,如果在資源服務(wù)器中使用到了受保護(hù)的資源,我們將通過 Authorization 頭來使用它。

結(jié)論

我們已經(jīng)學(xué)習(xí)了如何使用 OAuth2 授權(quán)我們的應(yīng)用。

本教程的完整實(shí)現(xiàn)可以在此 GitHub 項目中找到。

原文示例代碼

https://github.com/eugenp/spring-security-oauth/

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,882評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,208評論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,746評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,666評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,477評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,960評論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,047評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,200評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,726評論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,617評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,807評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,327評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,049評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,425評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,674評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,432評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,769評論 2 372

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