Spring Security基于DB的权限认证

前言

公众号 《java编程手记》记录JAVA学习日常,分享学习路上点点滴滴,从入门到放弃,欢迎关注

前面我们已经将一个简单的Spring Security Demo项目跑起来了,但是使用的是Spring Security自带默认的user用户名以及默认自动生成的密码,本文主要在原有的基础上加入更加适合生产环境使用的基于DB的权限认证,整体实现主要分为两个部分

  • 基于DB的权限表设计
  • Spring Security认证扩展点实现

基于DB的权限表设计

RBAC介绍

RBAC是基于角色的访问控制Role-Based Access Control ),在RBAC的设置中,用户和角色进行绑定,角色和权限进行绑定,一个用户可以有多个角色,一个角色也可以有多个权限,用户和权限点之间通过角色进行链接,

如下就是经典的表结构设计,用户表,角色表,权限表,用户角色表,角色权限表

用户表

CREATE TABLE `user` (
            `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
            `username` varchar(10) NOT NULL DEFAULT '' COMMENT '用户名',
            `password` varchar(255) NOT NULL DEFAULT '' COMMENT '密码',
            `name` varchar(20) NOT NULL DEFAULT '' COMMENT '用户名',
            `email` varchar(36) NOT NULL COMMENT '邮箱',
            `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
            `sex` tinyint(2) NOT NULL DEFAULT '0' COMMENT '性别',
            `age` tinyint(2) DEFAULT '0' COMMENT '年龄',
            `user_type` tinyint(2) NOT NULL DEFAULT '1' COMMENT '用户类别[0:管理员,1:普通员工]',
            `locked` tinyint(2) DEFAULT '0' COMMENT '是否锁定[0:正常,1:锁定]',
            `status` tinyint(3) NOT NULL DEFAULT '1' COMMENT '状态[0:失效,1:正常]',
            `create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
            `update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
            PRIMARY KEY (`id`),
            UNIQUE KEY `IDX_username` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

角色表


CREATE TABLE `role` (
        `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
        `name` varchar(64) NOT NULL COMMENT '角色名',
        `description` varchar(255) DEFAULT NULL COMMENT '简介',
        `icon_cls` varchar(32) DEFAULT NULL COMMENT '角色图标',
        `seq` tinyint(2) NOT NULL DEFAULT '0' COMMENT '排序号',
        `status` tinyint(2) NOT NULL DEFAULT '1' COMMENT '状态[0:失效,1:正常]',
        `create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
        `update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
        PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8 COMMENT='角色';


用户角色表

CREATE TABLE `user_role` (
            `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
            `user_id` int(11) NOT NULL COMMENT '用户id',
            `role_id` int(11) NOT NULL COMMENT '角色id',
            PRIMARY KEY (`id`),
            KEY `idx_user_role_ids` (`user_id`,`role_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=79 DEFAULT CHARSET=utf8 COMMENT='用户角色';

权限表

CREATE TABLE `resource` (
            `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
            `name` varchar(64) NOT NULL COMMENT '资源名称',
            `permissions` varchar(32) DEFAULT NULL COMMENT '资源的权限',
            `url` varchar(100) DEFAULT NULL COMMENT '资源路径',
            `open_mode` varchar(32) DEFAULT NULL COMMENT '打开方式 ajax,iframe',
            `description` varchar(255) DEFAULT NULL COMMENT '资源介绍',
            `icon_cls` varchar(32) DEFAULT NULL COMMENT '资源图标',
            `pid` int(11) DEFAULT NULL COMMENT '父级资源id',
            `seq` tinyint(2) NOT NULL DEFAULT '0' COMMENT '排序',
            `status` tinyint(2) NOT NULL DEFAULT '1' COMMENT '状态[0:失效,1:正常]',
            `opened` tinyint(1) NOT NULL DEFAULT '0' COMMENT '打开状态',
            `resource_type` tinyint(2) NOT NULL DEFAULT '0' COMMENT '资源类别',
            `create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
            `update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '更新时间',
            PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=239 DEFAULT CHARSET=utf8 COMMENT='资源';

角色权限表


CREATE TABLE `role_resource` (
        `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
        `role_id` int(11) NOT NULL COMMENT '角色id',
        `resource_id` int(11) NOT NULL COMMENT '资源id',
        PRIMARY KEY (`id`),
        KEY `idx_role_resource_ids` (`role_id`,`resource_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=683 DEFAULT CHARSET=utf8 COMMENT='角色资源';

将上述SQL导入到DB中即可

Mybatis-Plus 引入

https://mybatis.plus/guide/install.html

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

愿景

我们的愿景是成为 MyBatis 最好的搭档,就像 魂斗罗 中的 1P、2P,基友搭配,效率翻倍。

添加mybatis-plus SpringBoot && Mysql 驱动依赖

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.2</version>
</dependency>

<dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.38</version>
        </dependency>

application.yml配置

这里填写自身的DB信息即可

# DataSource Config
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security?useUnicode=true&useSSL=false&characterEncoding=utf8
    username: root
    password: 123456

代码自动生成

添加mybatis-plus-generator依赖,用以自动生成代码

这里发现一个小坑,mybatis-plus-generator自带的freemarker包有问题,需要引入一个新的版本(2.3.28)才可以正常执行

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.4.2</version>
</dependency>


<dependency>
  <groupId>org.freemarker</groupId>
  <artifactId>freemarker</artifactId>
  <version>2.3.28</version>
  <scope>compile</scope>
</dependency>

使用Mybatis-Plus提供的Demo,我们自动生成表的Controller,Service,DAO,Mapper文件


    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("请输入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotBlank(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("请输入正确的" + tip + "!");
    }

    public static void main(String[] args) {
        // 代码生成器
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setOutputDir(projectPath + "/src/main/java");
        gc.setAuthor("uiaoo");
        gc.setOpen(false);
        // gc.setSwagger2(true); 实体属性 Swagger2 注解
        mpg.setGlobalConfig(gc);

        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/security?useUnicode=true&useSSL=false&characterEncoding=utf8");
        // dsc.setSchemaName("public");
        dsc.setDriverName("com.mysql.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("123456");
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setModuleName(scanner("模块名"));
        pc.setParent("com.uiaoo.spring.security");
        mpg.setPackageInfo(pc);

        // 自定义配置
        InjectionConfig cfg = new InjectionConfig() {
            @Override
            public void initMap() {
                // to do nothing
            }
        };

        // 如果模板引擎是 freemarker
        String templatePath = "/templates/mapper.xml.ftl";
        // 自定义输出配置
        List<FileOutConfig> focList = new ArrayList<>();
        // 自定义配置会被优先输出
        focList.add(new FileOutConfig(templatePath) {
            @Override
            public String outputFile(TableInfo tableInfo) {
                // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
                return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
                        + "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
            }
        });

        cfg.setFileOutConfigList(focList);
        mpg.setCfg(cfg);

        // 配置模板
        TemplateConfig templateConfig = new TemplateConfig();

        templateConfig.setXml(null);
        mpg.setTemplate(templateConfig);

        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        strategy.setNaming(NamingStrategy.underline_to_camel);
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        strategy.setEntityLombokModel(true);
        strategy.setRestControllerStyle(true);
        strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
        strategy.setControllerMappingHyphenStyle(true);
        strategy.setTablePrefix(pc.getModuleName() + "_");
        mpg.setStrategy(strategy);
        mpg.setTemplateEngine(new FreemarkerTemplateEngine());
        mpg.execute();
    }

自动生成后的目录大致如下,包含了大部分常规的代码文件

Spring Security认证扩展点实现

SpringSecurityFilterChain

Spring Security 在web场景的应用核心实现为Bean name为SpringSecurityFilterChain的这个Bean,Class为org.springframework.security.web.FilterChainProxy,SpringSecurityFilterChain中内部维护了一个FilterChain,默认FilterChain中会维护如下Filter

UsernamePasswordAuthenticationFilter

后续我们会意义讲解每个Filter的实现作用,这里我们重点了解下SpringSecurityFilterChain这个Filter实现,看名字就可以大致猜出来是跟登录的账户密码相关联的filter,UsernamePasswordAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter在执行doFilter方法后会进入到attemptAuthentication这个方法中,即尝试认证,这里需要注意的一个点是,Authentication使用的实现类是UsernamePasswordAuthenticationToken,在后续的AuthenticationProvidersupports方法中将匹配到DaoAuthenticationProvider的实现

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            username = username != null ? username : "";
            username = username.trim();
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

AuthenticationManager

方法最后是this.getAuthenticationManager().authenticate(authRequest),即AuthenticationManager#authenticate方法,AuthenticationManager类抽象了认证的模型,从authenticate方法描述中可知,尝试去通过认证,返回一个填充了用户信息和认证信息的结果数据,

ProviderManager

Spring Security默认提供了AuthenticationManager的实现类ProviderManager,在providerManagerauthenticate方法实现中,providerManager设想认证方式可能会有多种,例如常规的账户密码认证,三方授权认证等等,主要是遍历所有的AuthenticationProvider的实现,通过provider.supports方法识别当前传入的authentication对象实现是否是当前provider所支持的,如果不支持则跳过,直到找到一个匹配的,则执行provider.authenticate方法

Class<? extends Authentication> toTest = authentication.getClass();
//拿到所有的AuthenticationProvider实现,循环遍历,如果supports,进行认证,否则下一个Provider
for (AuthenticationProvider provider : getProviders()) {
            if (!provider.supports(toTest)) {
                continue;
            }
            ......
            try {
                result = provider.authenticate(authentication);
                if (result != null) {
                    ....
                }
            }
            catch (){
        ....
      }
        }

AuthenticationProvider

AuthenticationProvider方法中定义了authenticate方法supports方法

  • supports 当前authentication是否适配当前Provider,还记得上面UsernamePasswordAuthenticationFilterauthentication的实现UsernamePasswordAuthenticationToken吗,这里将默认匹配到DaoAuthenticationProviderDaoAuthenticationProvider本身并没有实现supports方法,真正的实现是AbstractUserDetailsAuthenticationProvider,而AbstractUserDetailsAuthenticationProvider的实现只有DaoAuthenticationProvider,所以默认就匹配了DaoAuthenticationProvider
  • authenticate 真正的认证方法

默认AuthenticationProvider的核心实现AbstractUserDetailsAuthenticationProvider实现了大部分的通用关键逻辑方法authenticatesupports方法, 并且提供了扩展抽象方法retrieveUser ,当从缓存(默认缓存实现也是空的NullUserCache)中取不到用户信息时,将调用retrieveUser方法查询用户信息,DaoAuthenticationProvider实现了retrieveUser方法,

public abstract class AbstractUserDetailsAuthenticationProvider
        implements AuthenticationProvider, InitializingBean, MessageSourceAware {
@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
        String username = determineUsername(authentication);
        boolean cacheWasUsed = true;
    //从缓存中获取用户信息
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false;
            try {
        // 查询用户信息
                user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException ex) {
                this.logger.debug("Failed to find user '" + username + "'");
                if (!this.hideUserNotFoundExceptions) {
                    throw ex;
                }
                throw new BadCredentialsException(this.messages
                        .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }
            ...
        }
 }
  
  //authentication的实现UsernamePasswordAuthenticationToken
  @Override
  public boolean supports(Class<?> authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
  }
}

DaoAuthenticationProvider的实现中,出现了一个新的服务UserDetailsServiceUserDetailsService是一个获取用户信息的核心服务接口,只有一个方法loadUserByUsername,通过userName查询,返回封装后的用户信息UserDetails对象,分析到这里终于可以告一段落,虽然Spring Security也提供了默认的实现比如JdbcUserDetailsManager,但是整体还是不够灵活,我们可以从这里入手实现自己的UserDetailsService

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
@Override
    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        prepareTimingAttackProtection();
        try {
      //调用UserDetailsService.loadUserByUsername获取用户信息
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException(
                        "UserDetailsService returned null, which is an interface contract violation");
            }
            return loadedUser;
        }
        catch (UsernameNotFoundException ex) {
            mitigateAgainstTimingAttack(authentication);
            throw ex;
        }
        catch (InternalAuthenticationServiceException ex) {
            throw ex;
        }
        catch (Exception ex) {
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
        }
    }
}

说的有点多,画个图好理解下

实现

实现AuthenticationProvider

这里我们直接继承实现DaoAuthenticationProvider类,什么也不做,直接使用DaoAuthenticationProvider原有的authenticate方法实现

public class MyAuthenticationProvider extends DaoAuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        return super.authenticate(authentication);
    }
}

实现UserDetailsService

@Slf4j
@Component
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private IUserService iUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //判断用户是否存在
        User userInfo = iUserService.getAdminByUserName(username);
        if(Objects.isNull(userInfo)){
            throw new UsernameNotFoundException("用户不存在");
        }
                //根据用户名查询权限信息
        List<Resource> resourceList = iUserService.getResourcesByUserName(username);
        List<SimpleGrantedAuthority> authList = resourceList.stream().filter(v-> !StringUtils.isEmpty(v.getPermissions())).map(v -> new SimpleGrantedAuthority(v.getPermissions())).collect(Collectors.toList());
                // {noop} 不使用密码加密
        User user = new User(username,"{noop}"+userInfo.getPassword(),authList);
        log.info("user info : {}",user);
        return user;
    }
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
    public List<Resource> getResourcesByUserName(String userName) {
        //查询用户基础信息
        User user = getAdminByUserName(userName);
        if(Objects.isNull(user)){
            return new ArrayList<>();
        }
        //查询用户关联角色
        List<UserRole> tAdminRoleList = iUserRoleService.getRolesByUserId(user.getId());
        List<Integer> roleIds = new ArrayList<>();
        tAdminRoleList.forEach(tAdminRole -> {
            roleIds.add(tAdminRole.getRoleId());
        });
        //根据角色id查询关联权限信息
        return iRoleResourceService.getResource(roleIds);
    }
}

实现WebSecurityConfigurerAdapter配置项

  • EnableWebSecurity 启动SpringSecurity在web场景的自动装配
  • MapperScan({"com.smallcannon.spring.security.system.mapper"}) mybatis自动扫描mapper包
  • 定义/add路径访问需要add权限,/del需要 del权限
@EnableWebSecurity
@MapperScan({"com.smallcannon.spring.security.system.mapper"})
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    MyUserDetailsService myUserDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().and().authorizeRequests().antMatchers("/add").hasAuthority("add").and().authorizeRequests().antMatchers("/del").hasAuthority("del");
    }


        //设置自定义实现的AuthenticationProvider
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

    //设置自定义Provider,并将UserDetailService实现放进来
    @Bean
    public AuthenticationProvider authenticationProvider(){
        MyAuthenticationProvider provider = new MyAuthenticationProvider();
        provider.setUserDetailsService(myUserDetailsService);
        return provider;
    }

}

启动类,同事新增两个请求地址 /add /del

@SpringBootApplication
@RestController
public class StudySecurityApplication {

   public static void main(String[] args) {
      SpringApplication.run(StudySecurityApplication.class, args);
   }

   @GetMapping("/add")
   public Object add(){
      return "add";
   }

   @GetMapping("/del")
   public Object del(){
      return "del";
   }
}

在库中新增一个管理员角色,并且关联admin账户,新增一个创建权限add,并且将管理员角色关联到权限add,这样在访问我们的/add页面时就会返回正常的页面,返回del页面时就会返回无权限

权限add

管理员角色

用户admin

admin账户关联管理员角色

管理员角色关联add权限

启动应用

登录之后,访问/add 页面,成功返回add

访问/del 页面则显示403forbidden,权限不足,大功告成!

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

推荐阅读更多精彩内容