Spring Boot 之四:保護 Spring 應用

??Web 應用容易遭到各種攻擊逮栅,所以采用一些安全措施來保護應用正常使用和信息不被竊取篡改非常必要跨释。Spring Security 就是這么一個組件照筑。

1吹截、啟用

??要使用 Spring Security,添加對應的 starter 即可:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>   
<!-- 如果使用的是thymeleaf模板要引入下面的依賴 -->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>   

??啟動應用凝危,日志中會打印隨機生成密碼信息波俄,默認用戶名是 user

Using generated security password: 9d05346a-e240-46cc-9c53-87de94f5734d

??訪問應用,會首先跳到登錄頁 http://localhost:8080/login

??輸入正確的用戶名和密碼蛾默,就有權限訪問應用了懦铺。
??通過將 Security Starter 添加到項目的構建文件中,可以得到如下的安全特性:

  • 所有的 HTTP 請求路徑都需要認證支鸡;
  • 不需要特定的角色和權限冬念;
  • 沒有登錄頁面趁窃;
  • 認證過程是通過 HTTP basic 認證對話框實現(xiàn)的;
  • 系統(tǒng)只有一個用戶急前,用戶名為 user醒陆。
可以看到以上的安全特性,充其量只能稱之為一個 demo裆针,并不能真正地滿足以下的功能需求:
  • 通過登錄頁面提示客戶進行認證刨摩,而不是使用 HTTP basic 對話框;
  • 提供多個用戶据块,并提供一個注冊頁面码邻,這樣新用戶能夠注冊進來;
  • 對不同的請求路徑另假,執(zhí)行不同的安全規(guī)則。比如主頁和注冊頁面根本不需要進行認證怕犁。
    為了滿足上述要求边篮,需要做一些顯式的配置,覆蓋掉自動配置為我們提供的功能奏甫。



2戈轿、配置

??需要定義一個 Spring Security 的基礎配置類,該安全類要繼承 WebSecurityConfigurerAdapter 類阵子,并重寫 configure(AuthenticationManagerBuilder auth) 方法思杯。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {  
        ...
    }
  
    /**
     * 保護web請求,定義授權規(guī)則
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {      
        /**
         * 1挠进、formLogin():指定支持基于表單的身份驗證
         * 2色乾、未使用 FormLoginConfigurer#loginPage(String) 指定登錄頁時,將自動生成一個登錄頁面领突,親測此頁面引用的是聯(lián)網(wǎng)的 bootStrap 的樣式暖璧,所以斷網(wǎng)時,樣式會有點怪
         * 3君旦、當用戶沒有登錄澎办、沒有權限時默認會自動跳轉到登錄頁面(默認 /login),當?shù)卿浭r,默認跳轉到 /login?error,登錄成功時會放行
         * 4金砍、.defaultSuccessUrl("/design", true):強制要求用戶在登錄之后統(tǒng)一跳轉到"/design"頁面
         */     
        http.formLogin().loginPage("/login");      
    }
}

/**
 * 密碼編碼局蚀。Spring Security 高版本必須進行密碼編碼,否則報錯
 */
class MyPasswordEncoder implements PasswordEncoder {
    @Override
    public String encode(CharSequence charSequence) {
        return charSequence.toString();
    }
 
    @Override
    public boolean matches(CharSequence charSequence, String s) {
        return s.equals(charSequence.toString());
    }
}

??在 configure() 方法中恕稠,配置用戶存儲琅绅,Spring Security 為配置用戶存儲提供了多個可選方案,包括:

  • 基于內存的用戶存儲
  • 基于 JDBC 的用戶存儲
  • 以 LDAP 作為后端的用戶存儲
  • 自定義用戶詳情服務

?? configure(HttpSecurity http) 方法 Web 請求保護授權規(guī)則谱俭,目前還沒有配置任何規(guī)則奉件,默認訪問所有頁面都需要保證先登錄宵蛀,http.formLogin().loginPage("/login") 表示使用了指定的自定義登錄頁面的 URL,如果只是簡單訪問頁面县貌,則在視圖控制器中添加以下一行即可:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // ...
        // 不設置視圖名术陶,則默認跟路徑名相同,即http://localhost:8080/login 訪問的是login.html
        registry.addViewController("/login");
    }
}

??login.html 代碼如下:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" 
      xmlns:th="http://www.thymeleaf.org">
  <head>
    <title>Taco Cloud</title>
  </head>
  
  <body>
    <h1>Login</h1>
    <img th:src="@{/images/TacoCloud.png}"/>
    
    <div th:if="${error}"> 
      Unable to login. Check your username and password.
    </div>
    
    <p>New here? Click 
       <a th:href="@{/register}">here</a> to register.</p>
    
    <!-- tag::thAction[] -->
    <form method="POST" th:action="@{/login}" id="loginForm">
    <!-- end::thAction[] -->
      <label for="username">Username: </label>
      <input type="text" name="username"/><br/>
      
      <label for="password">Password: </label>
      <input type="password" name="password"/><br/>
      
      <input type="submit" value="Login"/>
    </form>
  </body>
</html>

(1)基于內存的用戶存儲

??下面展現(xiàn)了配置兩個用戶:

auth.inMemoryAuthentication()
    .passwordEncoder(new MyPasswordEncoder())
    .withUser("buzz").password("infinity").roles("USER")    
     // 高版本使用roles(xxx)不使用authorities(xxx)
    .and()
    .withUser("woody").password("bullseye").roles("USER");

??用戶密碼為:buzz/infinity煤痕、woody/bullseye梧宫,授權用戶角色是 USER。

(2)自定義用戶認證

??自定義用戶認證摆碉,既可以自行定義表結構來注冊用戶和用戶登錄認證塘匣。

數(shù)據(jù)表

create table if not exists Taco_User (
    id identity,
    username varchar(50) not null,
    password varchar(256) not null,
    fullname varchar(50) not null,
    street varchar(50) not null,
    city varchar(50) not null,
    state varchar(50) not null,
    zip varchar(50) not null,
    phone_number varchar(50) not null
);


領域對象

package tacos.jpa.domain;

import java.util.Arrays;
import java.util.Collection;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;

@Data
@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
@RequiredArgsConstructor
@Table(name = "Taco_User")
public class User implements UserDetails {

    private static final long serialVersionUID = 1L;

    private final String username;
    private final String password;  
    
    private final String fullname;
    private final String street;
    private final String city;
    private final String state;
    private final String zip;
    private final String phoneNumber;   
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;    
    
    // 返回用戶被授予權限的一個集合
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 所有的用戶都被授予USER權限
        return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
    }

    // 用戶的賬戶是否可用或者過期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 用戶的賬戶是否被未鎖定
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {      
        return true;
    }

    @Override
    public boolean isEnabled() {        
        return true;
    }
}

??類上用了例行的 JPA、lombok 注解巷帝,因為用戶表名跟實體類名不一致忌卤,所以要用 @Table 來顯式標注。

??通過實現(xiàn) Spring Security 的 UserDetails 接口楞泼,能夠提供更多信息給框架驰徊,比如用戶都被授予了哪些權限以及用戶的賬號是否可用。

??getAuthorities() 返回用戶被授予權限的一個集合堕阔,這里表明所有的用戶都被授予了 ROLE_USER 權限棍厂。各種 is...Expired() 方法要返回一個 boolean 值,表明用戶的賬號是否可用或過期超陆。

Repository 接口

package tacos.jpa.data;

import org.springframework.data.repository.CrudRepository;

import tacos.jpa.domain.User;

public interface UserRepository extends CrudRepository<User, Long> {

    User findByUsername(String username);
    
}

??使用 JPA牺弹,因此繼承了 CrudRepository 接口,除此之外還自定義了一個 findByUsername() 方法时呀,Spring Data JPA 會在運行時自動生成這個接口的實現(xiàn)张漂,相當于下面 SQL 的執(zhí)行效果:

select * from Taco_User where username = #{username};


創(chuàng)建用戶詳情服務

??Spring Security 的 UserDetailsService 接口在認證用戶時要用到,因此需要定義一個實現(xiàn)類退唠,并注入到 SecurityConfig 中鹃锈。

package tacos.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import tacos.jpa.data.UserRepository;
import tacos.jpa.domain.User;

@Service
public class UserRepositoryUserDetailsService implements UserDetailsService {

    private UserRepository userRepo;
    
    @Autowired
    public UserRepositoryUserDetailsService(UserRepository userRepo) {
        this.userRepo = userRepo;
    }
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepo.findByUsername(username);
        if (user != null) {
            return user;
        }
        throw new UsernameNotFoundException("User '" + username + "' not found");
    }

}

??loadUserByUsername() 會在用戶登錄認證時用到,實現(xiàn)中先是注入了上下文的 UserRepository 對象瞧预,然后通過它來查詢用戶是否存在屎债,存在則返回,不存在則拋出一個 UsernameNotFoundException 異常垢油。

配置使用自定義的用戶認證

??萬事俱備盆驹,現(xiàn)在在 configure(AuthenticationManagerBuilder auth) 方法里使用

/**
 * 自定義用戶認證
 * 使用Spring Security的UserDetails、UserDetailsService接口
 */
auth.userDetailsService(userDetailsService)
    .passwordEncoder(encoder());                // 設置密碼轉碼器

??用戶的密碼明文儲存在數(shù)據(jù)庫里是不安全的滩愁,因此密碼需要先經(jīng)過加密轉碼后的處理才能保存到數(shù)據(jù)庫中躯喇,這里需要在 SecurityConfig 中設置密碼轉碼器。

@Bean
public PasswordEncoder encoder() {
    return new StandardPasswordEncoder("53cr3t");
}

??passwordEncoder() 方法可以接受 Spring Security 中 PasswordEncoder 接口的任意實現(xiàn)。Spring Security 的加密模塊包括了多個這樣的實現(xiàn)廉丽。

  • BCryptPasswordEncoder:使用 bcrypt 強哈希加密倦微。
  • NoOpPasswordEncoder:不進行任何轉碼。
  • Pbkdf2PasswordEncoder:使用 PBKDF2 加密正压。
  • SCryptPasswordEncoder:使用 scrypt 加密欣福。
  • PasswordEncoder:使用 SHA-256 哈希加密。

??也可使用自定義的 PasswordEncoder 實現(xiàn)焦履。

注冊用戶

??現(xiàn)在已經(jīng)有了自定義的用戶詳情服務拓劝,需要定義一個控制器來展現(xiàn)和處理用戶注冊,代碼如下:

package tacos.security;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import tacos.jpa.data.UserRepository;
import tacos.jpa.domain.User;

@Controller
@RequestMapping("/register")
public class RegistrationController {
    private UserRepository userRepo;
    private PasswordEncoder passwordEncoder;
    
    public RegistrationController(UserRepository userRepo, PasswordEncoder passwordEncoder) {       
        this.userRepo = userRepo;
        this.passwordEncoder = passwordEncoder;
    }
    
    // 用戶注冊頁
    @GetMapping
    public String registerForm() {
        return "registration";
    }   
    
    // 接收表單上送的數(shù)據(jù)嘉裤,注冊用戶
    @PostMapping
    public String processRegistration(RegistrationForm form) {
        User user = form.toUser(passwordEncoder);
        userRepo.save(user);
        return "redirect:/login";
    }   
}

??用戶訪問 "/register" 即可訪問用戶注冊頁郑临,對應的模板是 registration.html,代碼如下:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" 
      xmlns:th="http://www.thymeleaf.org">
  <head>
    <title>Taco Cloud</title>
  </head>
  
  <body>
    <h1>Register</h1>
    <img th:src="@{/images/TacoCloud.png}"/>    
    
    <form method="POST" th:action="@{/register}" id="registerForm">
    
        <label for="username">Username: </label>
        <input type="text" name="username"/><br/>

        <label for="password">Password: </label>
        <input type="password" name="password"/><br/>

        <label for="confirm">Confirm password: </label>
        <input type="password" name="confirm"/><br/>

        <label for="fullname">Full name: </label>
        <input type="text" name="fullname"/><br/>
    
        <label for="street">Street: </label>
        <input type="text" name="street"/><br/>
    
        <label for="city">City: </label>
        <input type="text" name="city"/><br/>
    
        <label for="state">State: </label>
        <input type="text" name="state"/><br/>
    
        <label for="zip">Zip: </label>
        <input type="text" name="zip"/><br/>
    
        <label for="phone">Phone: </label>
        <input type="text" name="phone"/><br/>
    
        <input type="submit" value="Register"/>
    </form>
    
  </body>
</html>

??表單提交時會由 processRegistration() 方法響應處理屑宠,定義一個 RegistrationForm 類來映射綁定提交的表單數(shù)據(jù)厢洞,代碼如下:

package tacos.security;

import org.springframework.security.crypto.password.PasswordEncoder;

import lombok.Data;
import tacos.jpa.domain.User;

@Data
public class RegistrationForm {

    private String username;
    private String password;
    private String fullname;
    private String street;
    private String city;
    private String state;
    private String zip;
    private String phone;
    
    public User toUser(PasswordEncoder passwordEncoder) {
        return new User(username, passwordEncoder.encode(password), fullname, street, city, state, zip, phone);
    }
    
}

??轉化為 User 對象時,會傳入轉碼器對象典奉,對密碼進行轉碼處理犀变,然后再持久化到數(shù)據(jù)庫中。


3秋柄、保護 Web 請求

(1)授權規(guī)則

??修改 SecurityConfig 的 configure(HttpSecurity http) 方法。

    /**
     * 保護web請求蠢正,定義授權規(guī)則
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {      
        http.authorizeRequests()
            .antMatchers("/design", "/orders").hasRole("USER")
            .antMatchers("/", "/**").permitAll();
    
        http.formLogin().loginPage("/login");
        
        http.logout().logoutSuccessUrl("/");        
        
        http.csrf().ignoringAntMatchers("/h2-console/**", "/design/**", "/orders/**");        
        http.headers().frameOptions().sameOrigin();
    }
  • Ⅰ骇笔、只有經(jīng)過認證的角色為 USER 的用戶才能訪問 "/design"、"/orders"嚣崭,而其他請求對所有客戶均可用笨触。
  • Ⅱ、使用指定的登錄 URL雹舀,綁定自定義的登錄頁面芦劣。
  • Ⅲ、用戶登出后跳轉的 URL
  • Ⅳ说榆、默認情況下 Spring 會開啟 csrf 攻擊防護虚吟,如果是 POST 請求,則必須驗證 Token签财,如果沒有串慰,就會報錯 403,無權限訪問唱蒸,即使上面對目標請求路徑授權了也不行邦鲫,這里配置對一些路徑的訪問忽略防護。
  • Ⅴ神汹、h2-console 默認禁止頁面展示 <iframe> 標簽庆捺,設置同源策略即可古今。

(2)防止跨站請求偽造

??跨站請求偽造(Cross-Site Request Forgery,CSRF)是一種常見的安全攻擊滔以。它會讓用戶在一個惡意的 Web 頁面上填寫信息捉腥,然后自動將表單以攻擊受害者的身份提交到另一個應用上。

??例如醉者,用戶看到一個來自攻擊者的 Web 站點的表單但狭,這個站點會自動將數(shù)據(jù) POST 到用戶銀行 Web 站點的 URL 上(這個站點可能缺乏安全防護),實現(xiàn)轉賬的操作撬即。用戶可能根本不知道發(fā)生了攻擊立磁,直到他們發(fā)現(xiàn)賬號上的錢已經(jīng)不翼而飛。

??為了防止這種類型的攻擊剥槐,應用可以在展現(xiàn)表單的時候生成一個 CSRF Token唱歧,并放到隱藏域中,然后將其臨時存儲起來粒竖,以便后續(xù)在服務器上使用颅崩。在提交表單的時候,token 將和其他的表單數(shù)據(jù)一起發(fā)送至服務端蕊苗。請求會被服務端攔截沿后,并與最初生成的 token 進行對比。如果 token 匹配朽砰,那么請求將會允許處理尖滚;否則,表單肯定是惡意網(wǎng)站渲染的瞧柔,因為它不知道服務器所生成的 token漆弄。

??Spring Security 提供了內置的 CSRF 保護,默認是啟用的造锅。要保證應用的每個表單都有一個名為 "_csrf" 字段撼唾,它會持有 token。

??在 Thymeleaf 模板中哥蔚,可以在模板表單中嵌入以下隱藏域:

<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

??在 Thymeleaf 中倒谷,我們只需要確保 <form> 的某個屬性帶有 Thymeleaf 屬性前綴即可。例如肺素,為了讓Thymeleaf 渲染隱藏域恨锚,只需要使用 th:action 屬性即可。

??訪問該頁面時倍靡,可以看到被自動填充了一個 token猴伶。

??還可以禁用 Spring Security 對 CSRF 的支持,但是一般情況下該支持可以非常好地防護表單提交的安全,要禁用通過 disable() 來實現(xiàn)他挎。

http.csrf().disable();



4筝尾、獲取當前用戶

??有多種方式確定用戶是誰,常用的方式如下:

  • 注入 Principal 對象到控制器方法中办桨;
  • 注入 Authentication 對象到控制器方法中筹淫;
  • 使用 SecurityContextHolder 來獲取安全上下文;
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    User user = (User) authentication.getPrincipal();
    
  • 使用 @AuthenticationPrincipal 注解來標注方法呢撞。

??OrderController 的 processOrder() 方法參數(shù)添加一個 @AuthenticationPrincipal User 參數(shù)损姜。

/**
 * @AuthenticationPrincipal User user   獲取當前會話的用戶
 */
@PostMapping
public String processOrder(@Valid Order order, 
                         Errors errors, 
                         SessionStatus sessionStatus,
                         @AuthenticationPrincipal User user) {
    if (errors.hasErrors()) {
        return "jpa-orderForm";
    }
    
    order.setUserInfo(user);
    
    orderRepo.save(order);
    sessionStatus.setComplete();
    
    return "redirect:/jpa-orders/list";
}

【演示項目github地址】https://github.com/huyihao/Spring-Tutorial/tree/main/2%E3%80%81SpringBoot/taco-cloud-security

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市殊霞,隨后出現(xiàn)的幾起案子摧阅,更是在濱河造成了極大的恐慌,老刑警劉巖绷蹲,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件棒卷,死亡現(xiàn)場離奇詭異,居然都是意外死亡祝钢,警方通過查閱死者的電腦和手機比规,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來拦英,“玉大人蜒什,你說我怎么就攤上這事“坦溃” “怎么了吃谣?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長做裙。 經(jīng)常有香客問我,道長肃晚,這世上最難降的妖魔是什么锚贱? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮关串,結果婚禮上拧廊,老公的妹妹穿的比我還像新娘。我一直安慰自己晋修,他們只是感情好吧碾,可當我...
    茶點故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著墓卦,像睡著了一般倦春。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天睁本,我揣著相機與錄音尿庐,去河邊找鬼。 笑死呢堰,一個胖子當著我的面吹牛抄瑟,可吹牛的內容都是我干的。 我是一名探鬼主播枉疼,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼皮假,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了骂维?” 一聲冷哼從身側響起惹资,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎席舍,沒想到半個月后布轿,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡来颤,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年汰扭,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片福铅。...
    茶點故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡萝毛,死狀恐怖,靈堂內的尸體忽然破棺而出滑黔,到底是詐尸還是另有隱情笆包,我是刑警寧澤,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布略荡,位于F島的核電站庵佣,受9級特大地震影響,放射性物質發(fā)生泄漏汛兜。R本人自食惡果不足惜巴粪,卻給世界環(huán)境...
    茶點故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望粥谬。 院中可真熱鬧肛根,春花似錦、人聲如沸漏策。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽掺喻。三九已至芭届,卻和暖如春储矩,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背喉脖。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工椰苟, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人树叽。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓舆蝴,卻偏偏與公主長得像,于是被迫代替她去往敵國和親题诵。 傳聞我的和親對象是個殘疾皇子洁仗,可洞房花燭夜當晚...
    茶點故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內容