Shiro 这个安全框架小而美

写在前面

在一款应用的整个生命周期,我们都会谈及该应用的数据安全问题。用户的合法性与数据的可见性是数据安全中非常重要的一部分。但是,一方面,不同的应用对于数据的合法性和可见性要求的维度与粒度都有所区别;另一方面,以当前微服务、多服务的架构方式,如何共享Session,如何缓存认证和授权数据应对高并发访问都迫切需要我们解决。Shiro的出现让我们可以快速和简单的应对我们应用的数据安全问题

Shiro介绍

Shiro简介

这个官网解释不抽象,所以直接用官网解释:Apache Shiro™是一个强大且易用的 Java 安全框架,可以执行身份验证、授权、加密和会话管理等。基于 Shiro 的易于理解的API,您可以快速、轻松地使任何应用程序变得安全(从最小的移动应用到最大的网络和企业应用)。

谈及安全,多数 Java 开发人员都离不开 Spring 框架的支持,自然也就会先想到 Spring Security,那我们先来看二者的差别

Shiro Spring Security
简单、灵活 复杂、笨重
可脱离Spring 不可脱离Spring
粒度较粗 粒度较细

虽然 Spring Security 属于名震中外 Spring 家族的一部分,但是了解 Shiro 之后,你不会想 “嫁入豪门”,而是选择追求「诗和远方」冲动。

横看成岭侧成峰,远近高低各不同 (依旧是先了解概念就好)

远看 Shiro 看轮廓

image

Subject

它是一个主体,代表了当前“用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;即一个抽象概念;所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给SecurityManager;可以把 Subject 认为是一个门面;SecurityManager 才是实际的执行者

SecurityManager

安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,如果学习过 SpringMVC,你可以把它看成 DispatcherServlet前端控制器

Realm

域,Shiro 从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色/权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。

近看 Shiro 看细节

看图瞬间懵逼?别慌,会为你拆解来看,结合着图看下面的解释,这不是啥大问题,且看:

image

Subject

主体,可以看到主体可以是任何可以与应用交互的 “用户”

SecurityManager

相当于 SpringMVC 中的 DispatcherServlet;是 Shiro 的心脏;所有具体的交互都通过 SecurityManager 进行控制;它管理着所有 Subject、且负责进行认证和授权、及会话、缓存的管理

Authenticator

认证器,负责主体认证的,这是一个扩展点,如果用户觉得 Shiro 默认的不好,可以自定义实现;需要自定义认证策略(Authentication Strategy),即什么情况下算用户认证通过了

Authrizer

授权器,或者访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能

Realm

可以有 1 个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是JDBC实现,也可以是LDAP实现,或者内存实现等等;由用户提供;注意:Shiro 不知道你的用户/权限存储在哪及以何种格式存储;所以我们一般在应用中都需要实现自己的Realm

SessionManager

如果写过 Servlet 就应该知道 Session 的概念,Session 需要有人去管理它的生命周期,这个组件就是 SessionManager;而Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境、EJB等环境;所以,Shiro 就抽象了一个自己的Session 来管理主体与应用之间交互的数据;这样的话,比如我们在 Web 环境用,刚开始是一台Web服务器;接着又上了台EJB 服务器;这时又想把两台服务器的会话数据放到一个地方,我们就可以实现自己的分布式会话(如把数据放到Memcached 服务器)

SessionDAO

DAO大家都用过,数据访问对象,用于会话的 CRUD,比如我们想把 Session 保存到数据库,那么可以实现自己的SessionDAO,通过如JDBC写到数据库;比如想把 Session 放到 Memcached 中,可以实现自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能;

CacheManager

缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少去改变,放到缓存中后可以提高访问的性能

Cryptography

密码模块,Shiro提高了一些常见的加密组件用于如密码「加密/解密」的

注意上图的结构,我们会根据这张图来逐步拆分讲解,记住这张图也更有助于我们理解 Shiro 的工作原理,所以依旧是打开两个网页一起看就好喽

搭建概览

多数小伙伴都在使用 Spring Boot, Shiro 也很应景的定义了 starter,做了更好的封装,对于我们来说使用起来也就更加方便,来看选型概览

序号 名称 版本
1 Springboot 2.0.4
2 JPA 2.0.4
3 Mysql 8.0.12
4 Redis 2.0.4
5 Lombok 1.16.22
6 Guava 26.0-jre
7 Shiro 1.4.0

使用 Spring Boot,大多都是通过添加 starter 依赖,会自动解决依赖包版本,所以自己尝试的时候用最新版本不会有什么问题,比如 Shiro 现在的版本是 1.5.0 了,整体问题不大,大家自行尝试就好

添加 Gradle 依赖管理

image

大体目录结构

image

application.yml 配置

image

基本配置

image
image

你就让我看这?这只是一个概览,先做到心中有数,我们来看具体配置,逐步完成搭建

其中 shiroFilter bean 部分指定了拦截路径和相应的过滤器,”/user/login”, ”/user”, ”/user/loginout” 可以匿名访问,其他路径都需要授权访问,shiro 提供和多个默认的过滤器,我们可以用这些过滤器来配置控制指定url的权限(先了解个大概即可):

配置缩写 对应的过滤器 功能
anon AnonymousFilter 指定url可以匿名访问
authc FormAuthenticationFilter 指定url需要form表单登录,默认会从请求中获取username、password,rememberMe等参数并尝试登录,如果登录不了就会跳转到loginUrl配置的路径。我们也可以用这个过滤器做默认的登录逻辑,但是一般都是我们自己在控制器写登录逻辑的,自己写的话出错返回的信息都可以定制嘛。
authcBasic BasicHttpAuthenticationFilter 指定url需要basic登录
Logout LogoutFilter 登出过滤器,配置指定url就可以实现退出功能,非常方便
noSessionCreation NoSessionCreationFilter 禁止创建会话
perms PermissionsAuthorizationFilter 需要指定权限才能访问
port PortFilter 需要指定端口才能访问
rest HttpMethodPermissionFilter 将http请求方法转化成相应的动词来构造一个权限字符串,这个感觉意义不大,有兴趣自己看源码的注释
roles RolesAuthorizationFilter 需要指定角色才能访问
ssl SslFilter 需要https请求才能访问
user UserFilter 需要已登录或“记住我”的用户才能访问

数据库表设计

数据库表设计请参考 entity package下的 bean,通过@Entity 注解与 JPA 的设置自动生成表结构 (你需要简单的了解一下 JPA 的功能)。

我们要说重点啦~~~

身份认证

身份认证是一个证明 “李雷是李雷,韩梅梅是韩梅梅” 的过程,回看上图,Realm 模块就是用来做这件事的,Shiro 提供了 IniRealm,JdbcReaml,LDAPReam等认证方式,但自定义的 Realm 通常是最适合我们业务需要的,认证通常是校验登录用户是否合法。

新建用户 User

@Data
@Entity
public class User implements Serializable {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    @Column(unique =true)
    private String username;

    private String password;

    private String salt;

}

定义 Repository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    public User findUserByUsername(String username);

}

编写UserController:

@GetMapping("/login")
public void login(String username, String password) {
    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    token.setRememberMe(true);
    Subject currentUser = SecurityUtils.getSubject();
    currentUser.login(token);
}

自定义 Realm

自定义 Realm,主要是为了重写 doGetAuthenticationInfo(…)方法

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
    String username = token.getUsername();
    User user = userRepository.findUserByUsername(username);
    SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user, user.getPassword(), getName());
    simpleAuthenticationInfo.setCredentialsSalt(ByteSource.Util.bytes(user.getSalt()));
    return simpleAuthenticationInfo;
}

这些代码我需要做一个说明,你可能也满肚子疑惑:

  1. 这段代码怎么应用了 shiro?
  2. controller 是怎么调用到 custom realm 的?
  3. 重写的 doGetAuthenticationInfo(…) 方法目的是什么?

认证流程说明

用户访问/user/login 路径,生成 UsernamePasswordToken, 通过SecurityUtils.getSubject()获取Subject(currentUser),调用 login 方法进行验证,让我们跟踪一下代码,瞧一瞧就知道自定义的CustomRealm怎样起作用的,一起来看源码:

image

到这里我们要停一停了,请回看 Shiro 近景图,将源码追踪路径与其对比,是完全一致的

授权

身份认证是验证你是谁的问题,而授权是你能干什么的问题,

产品经理:申购模块只能科室看
程序员:好的
产品经理:科长权限大一些,他也能看申购模块
程序员:好的(黑脸)
产品经理:科长不但能看,还能修改数据
程序员:关公提大刀,拿命来

作为程序员,我们的宗旨是:「能动手就不吵吵」; 硝烟怒火拔地起,耳边响起驼铃声(Shiro):「放下屠刀,立地成佛」授权没有那么麻烦,大家好商量…

整个过程和身份认证基本是一毛一样,你对比看看

角色实体创建

涉及到授权,自然要和角色相关,所以我们创建 Role 实体:

@Data
@Entity
public class Role {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    @Column(unique =true)
    private String roleCode;

    private String roleName;
}

新建 Role Repository

@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

    @Query(value = "select roleId from UserRoleRel ur where ur.userId = ?1")
    List<Long> findUserRole(Long userId);

    List<Role> findByIdIn(List<Long> ids);

}

定义权限实体 Permission

@Data
@Entity
public class Permission {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    @Column(unique =true)
    private String permCode;

    private String permName;
}

定义 Permission Repository

@Repository
public interface PermissionRepository extends JpaRepository<Permission, Long> {

    @Query(value = "select permId from RolePermRel pr where pr.roleId in ?1")
    List<Long> findRolePerm(List<Long> roleIds);

    List<Permission> findByIdIn(List<Long> ids);
}

建立用户与角色关系

其实可以通过 JPA 注解来制定关系的,这里为了说明问题,以单独外键形式说明

@Data
@Entity
public class UserRoleRel {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    private Long userId;

    private Long roleId;


}

建立角色与权限关系

@Data
@Entity
public class RolePermRel {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    private Long permId;

    private Long roleId;

}

编写 UserController

@RequiresPermissions("user:list:view")
@GetMapping()
public void getAllUsers(){
    List<User> users = userRepository.findAll();
}

@RequiresPermissions("user:list:view") 注解说明具有用户:列表:查看权限的才可以访问),官网明确给出权限定义格式,包括通配符等,我希望你自行去查看

自定义 CustomRealm (主要重写 doGetAuthorizationInfo) 方法:

image

与认证流程如出一辙,只不过多了用户,角色,权限的关系罢了

授权流程说明

这里通过过滤器(见Shiro配置)和注解二者结合的方式来进行授权,和认证流程一样,最终会走到我们自定义的 CustomRealm 中,同样 Shiro 默认提供了许多注解用来处理不同的授权情况

注解 功能
@RequiresGuest 只有游客可以访问
@RequiresAuthentication 需要登录才能访问
@RequiresUser 已登录的用户或“记住我”的用户能访问
@RequiresRoles 已登录的用户需具有指定的角色才能访问
@RequiresPermissions 已登录的用户需具有指定的权限才能访问(如果不想和产品经理华山论剑,推荐用这个注解)

授权官网给出明确的授权策略与案例,请查看:http://shiro.apache.org/permissions.html

上面的例子我们通过一直在通过访问 Mysql 获取用户认证和授权信息,这中方式明显不符合生产环境的需求

Session会话管理

做过 Web 开发的同学都知道 Session 的概念,最常用的是 Session 过期时间,数据在 Session 的 CRUD,同样看上图,我们需要关注 SessionManager 和 SessionDAO 模块,Shiro starter 已经提供了基本的 Session配置信息,我们按需在YAML中配置就好(官网https://shiro.apache.org/spring-boot.html 已经明确给出Session的配置信息)

Key Default Value Description
shiro.enabled true Enables Shiro’s Spring module
shiro.web.enabled true Enables Shiro’s Spring web module
shiro.annotations.enabled true Enables Spring support for Shiro’s annotations
shiro.sessionManager.deleteInvalidSessions true Remove invalid session from session storage
shiro.sessionManager.sessionIdCookieEnabled true Enable session ID to cookie, for session tracking
shiro.sessionManager.sessionIdUrlRewritingEnabled true Enable session URL rewriting support
shiro.userNativeSessionManager false If enabled Shiro will manage the HTTP sessions instead of the container
shiro.sessionManager.cookie.name JSESSIONID Session cookie name
shiro.sessionManager.cookie.maxAge -1 Session cookie max age
shiro.sessionManager.cookie.domain null Session cookie domain
shiro.sessionManager.cookie.path null Session cookie path
shiro.sessionManager.cookie.secure false Session cookie secure flag
shiro.rememberMeManager.cookie.name rememberMe RememberMe cookie name
shiro.rememberMeManager.cookie.maxAge one year RememberMe cookie max age
shiro.rememberMeManager.cookie.domain null RememberMe cookie domain
shiro.rememberMeManager.cookie.path null RememberMe cookie path
shiro.rememberMeManager.cookie.secure false RememberMe cookie secure flag
shiro.loginUrl /login.jsp Login URL used when unauthenticated users are redirected to login page
shiro.successUrl / Default landing page after a user logs in (if alternative cannot be found in the current session)
shiro.unauthorizedUrl null Page to redirect user to if they are unauthorized (403 page)

分布式服务中,我们通常需要将Session信息放入Redis中来管理,来应对高并发的访问需求,这时只需重写SessionDAO即可完成自定义的Session管理

整合Redis

@Configuration
public class RedisConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public RedisTemplate<String, Object> stringObjectRedisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }

}

重写SessionDao

image

查看源码,可以看到调用默认SessionManager的retriveSession方法,我们重写该方法,将Session放入HttpRequest中,进一步提高session访问效率

image

向ShiroConfig中添加配置

其实在概览模块已经给出代码展示,这里单独列出来做说明:

/**
 * 自定义RedisSessionDao用来管理Session在Redis中的CRUD
 * @return
 */
@Bean(name = "redisSessionDao")
public RedisSessionDao redisSessionDao(){
    return new RedisSessionDao();
}

/**
 * 自定义SessionManager,应用自定义SessionDao
 * @return
 */
@Bean(name = "customerSessionManager")
public CustomerWebSessionManager customerWebSessionManager(){
    CustomerWebSessionManager customerWebSessionManager = new CustomerWebSessionManager();
    customerWebSessionManager.setSessionDAO(redisSessionDao());
    return customerWebSessionManager;
}

/**
 * 定义Security manager
 * @param customRealm
 * @return
 */
@Bean(name = "securityManager")
public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm) {
    DefaultWebSecurityManager  securityManager = new DefaultWebSecurityManager ();
    securityManager.setRealm(customRealm);
    securityManager.setSessionManager(customerWebSessionManager()); // 可不指定,Shiro会用默认Session manager
    securityManager.setCacheManager(redisCacheManagers());  //可不指定,Shiro会用默认CacheManager
//        securityManager.setSessionManager(defaultWebSessionManager());
    return securityManager;
}

/**
 * 定义session管理器
 * @return
 */
@Bean(name = "sessionManager")
public DefaultWebSessionManager defaultWebSessionManager(){
    DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
    defaultWebSessionManager.setSessionDAO(redisSessionDao());
    return defaultWebSessionManager;
}

至此,将 session 信息由 redis 管理功能就这样完成了

缓存管理

应对分布式服务,对于高并发访问数据库权限内容是非常低效的方式,同样我们可以利用Redis来解决这一问题,将授权数据缓存到Redis中

新建 RedisCache

@Slf4j
@Component
public class RedisCache<K, V> implements Cache<K, V> {

    public static final String SHIRO_PREFIX = "shiro-cache:";

    @Resource
    private RedisTemplate<String, Object> stringObjectRedisTemplate;

    private String getKey(K key){
        if (key instanceof String){
            return (SHIRO_PREFIX + key);
        }
        return key.toString();
    }

    @Override
    public V get(K k) throws CacheException {
        log.info("read from redis...");
        V v = (V) stringObjectRedisTemplate.opsForValue().get(getKey(k));
        if (v != null){
            return v;
        }
        return null;
    }

    @Override
    public V put(K k, V v) throws CacheException {
        stringObjectRedisTemplate.opsForValue().set(getKey(k), v);
        stringObjectRedisTemplate.expire(getKey(k), 100, TimeUnit.SECONDS);
        return v;
    }

    @Override
    public V remove(K k) throws CacheException {
        V v = (V) stringObjectRedisTemplate.opsForValue().get(getKey(k));
        stringObjectRedisTemplate.delete((String) get(k));
        if (v != null){
            return v;
        }
        return null;
    }

    @Override
    public void clear() throws CacheException {
        //不要重写,如果只保存shiro数据无所谓
    }

    @Override
    public int size() {
        return 0;
    }

    @Override
    public Set<K> keys() {
        return null;
    }

    @Override
    public Collection<V> values() {
        return null;
    }
}

新建 RedisCacheManager

public class RedisCacheManager implements CacheManager {

    @Resource
    private RedisCache redisCache;

    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return redisCache;
    }
}

至此,我们不用每次访问 Mysql DB 来获取认证和授权信息,而是通过 Redis 来缓存这些信息,大大提升了效率,也满足分布式系统的设计需求

总结

回复公众号 「demo」获取 demo 代码。这里只是梳理了Springboot整合Shiro的流程,以及应用Redis最大化利用Shiro,Shiro的使用细节还很多,官网说的也很明确,带着上面的架构图来理解Shiro会事半功倍,感觉这里面的代码挺多挺头大的?那是你没有自己动手去尝试,结合官网与 demo 相信你会对 Shiro 有更好的理解,另外你可以理解 Shiro 是 mini 版本的 Spring Security,我希望以小见大,当需要更细粒度的认证授权时,也会对理解 Spring Security 有很大帮助,点击文末「阅读原文」,效果更好

落霞与孤鹜齐飞 秋水共长天一色,产品经理和程序员一片祥和…

灵魂追问

  1. 都说 Redis 是单线程,但是很快,你知道为什么吗?
  2. 你们项目中是怎样控制认证授权的呢?当授权有变化,对于程序员来说,这个修改是灾难吗?

提高效率工具

image

MarkDown 表格生成器

本文的好多表格是从官网粘贴的,如何将其直接转换成 MD table 呢?那么 https://www.tablesgenerator.com/markdown_tables 就可以帮到你了,无论是生成 MD table,还是粘贴内容生成 table 和内容都是极好的,当然了不止 MD table,自己发现吧,更多工具,公众号回复 「工具」获得

image

推荐阅读


欢迎持续关注公众号:「日拱一兵」

  • 前沿 Java 技术干货分享
  • 高效工具汇总 回复「工具」
  • 面试问题分析与解答
  • 技术资料领取 回复「资料」

以读侦探小说思维轻松趣味学习 Java 技术栈相关知识,本着将复杂问题简单化,抽象问题具体化和图形化原则逐步分解技术问题,技术持续更新,请持续关注......

image
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342