SpringSecurity+JWT登录认证

1. 简要

之前学习的认证方式,在服务器验证通过后,会在当前对话session中保存数据,然后向客户端返回一个session_id存在客户端Cookie中,但是这种模式会存在问题就是扩展性不好,对于单机还好,如果是服务器集群,就需要实现session共享,保证每台服务器都能读取session。

而今天要学的JWT(JSON Web Token)是目前比较留校的一种跨域认证方案,在前后端分离项目中应用的比较多。具体JWT的概念可以学习阮一峰大神的这篇文章:[JSON Web Token 入门教程 - 阮一峰](https://link.juejin.cn/?target=http%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2018%2F07%2Fjson_web_token-tutorial.html)

俺今天是要使用SpringSecurity实现JWT的认证,前后端分离,使用JSON交互。

2. 设计思路

首先是登录认证:

  1. 前端POST请求,将用户名和密码以JSON的形式发送请求/jwt/login
  2. 自定义一个JWTAuthenticationFilter,进行提取request中的参数,封装为一个UsernampasswordAuthenticationTokenAuthenticationManager进行认证
  3. AuthenticationManager从自定义的Service中查找用户信息,判断账号密码是否正确
  4. 认证成功则生成JWT Token给客户端
  5. 认证失败则返回错误

然后是请求时的Token校验:

因为使用JWT,所以不需要用到session,每一个请求都是需要携带Token,服务器根据Token进行验证,因此这里我们还需要一个自定义的Filter,用来拦截任意请求
  1. 一个请求发起
  2. 被自定义的Filter拦截,进行token验证
  3. 将验证成功的token生成Authentication对象存入SecurityContext上下文,表示验证完成
  4. 后续再进行权限的验证

3. 环境搭建

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.48</version>
        <scope>runtime</scope>
    </dependency>
    <!-- 引入jaxb-api包 -->
    <dependency>
        <groupId>javax.xml.bind</groupId>
        <artifactId>jaxb-api</artifactId>
        <version>2.3.0</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

这里需要注意的一个包是:jaxb-api

导入这个包是因为使用JJWT这个包时会报一个异常:

java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter at java.base/jdk.internal.loader

导入这个包就可以解决了。

Springboot配置mysql和mybatis

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/security
    driver-class-name: com.mysql.jdbc.Driver

mybatis:
  mapper-locations:
  - classpath:mapper/*.xml
  type-aliases-package: com.zzy.jwt.pojo

4. 源码实现

  1. 自定义JWTAuthenticationFilter

    public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
        /**
         * 通过构造器注入拦截的Url,请求方法没有限制
         * @param antPathRequestUrl url
         */
        public JWTAuthenticationFilter(String antPathRequestUrl) {
            super(new AntPathRequestMatcher(antPathRequestUrl, "POST"));
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
            // 从JSON中读取请求参数
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
    
            // 获取用户名和密码
            String username = user.getUsername();
            String password = user.getPassword();
    
            // 构造Token,使用UsernamepasswordAuthenticationToken
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
            // 设置一些客户端IP等信息
            setDetails(request, token);
            // 交给AuthenticationManager进行认证
            return this.getAuthenticationManager().authenticate(token);
        }
    
        protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
            authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
        }
    
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            Response myResponse = new Response();
            // 设置header
            response.setHeader("Content-Type", "application/json;charset=utf-8");
            User user = (User) authResult.getPrincipal();
            // 生成Token返回
            String token = JWTUtil.generate(user);
            myResponse.setStatusCode(HttpStatus.OK.value());
            myResponse.setMsg("登录成功!");
            myResponse.setData("Bearer " + token);
    
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().write(objectMapper.writeValueAsString(myResponse));
        }
    
    
        /**
         * 重写认证失败后的处理方法
         * @param request
         * @param response
         * @param failed
         * @throws IOException
         * @throws ServletException
         */
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                  AuthenticationException failed) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpStatus.UNAUTHORIZED.value());
            if (failed instanceof LockedException) {
                myResponse.setMsg("账号被锁定!");
            } else if (failed instanceof CredentialsExpiredException) {
                myResponse.setMsg("用户密码过期!");
            } else if (failed instanceof AccountExpiredException) {
                myResponse.setMsg("用户账号过期!");
            } else if (failed instanceof DisabledException) {
                myResponse.setMsg("用户账号被禁用!");
            } else if (failed instanceof BadCredentialsException) {
                myResponse.setMsg("用户账号或密码错误!");
            }
    
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write(objectMapper.writeValueAsString(myResponse));
        }
    

    自定义登录认证过滤器,这次拦截的url由构造器进行设置,重写attemptAuthentication方法逻辑与之前的自定义认证一样,都是从请求中获取用户名和密码等参数,封装为一个Token,我这里直接使用了官方的UsernamepasswordAuthenticationToken,然后交给AuthenticationManager去认证。

    这里的认证成功与失败没有使用自定义类的形式实现,而是以重写successfulAuthentication方法和unsuccessfulAuthentication方法实现,认证成功后生成一个JWT Token返回,这里直接将user对象作为JWT的playload保存,实际中应以实际情况写入的。认证失败则返回JSON字符串错误。

    需要与之前的验证码认证区分开的是,这里前后端是以JSON传递的,所以request接收到的是一个json字符串的数据,返回时也是json数据,相同的地方是Provider还是调用UserDetailsService的loadUserByUsername方法去验证用户。

    在这里也是自定义了一个Response类,用来构造返回信息,主要就是设置状态,返回信息,以及数据。

  2. 定义UserDetail类和UserDetailsService

    UserDetail类:

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    public class User implements UserDetails, Serializable {
    
        private static final long serialVersionUID = -1384206000980290583L;
    
        private int id;
        private String username;
        private String password;
        private String email;
        private boolean enabled;
    
        @Override
        public String toString() {
            return "User{" +
                    "id=" + id +
                    ", username='" + username + '\'' +
                    ", password='" + password + '\'' +
                    ", email='" + email + '\'' +
                    '}';
        }
    
        @Override
        @JsonDeserialize(using = CustomAuthorityDeserializer.class)
        public Collection<? extends GrantedAuthority> getAuthorities() {
            List<SimpleGrantedAuthority> authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority("ROLE_ROOT"));
            return authorities;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return enabled;
        }
    }
    

    User类没有太多变动,和之前一样,因为还没涉及到授权部分,这里暂时先写死权限位ROLE_ROOT.

    有两个错误可能会出现:

    1. com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "accountNonExpired" (class com.zzy.jwt.pojo.User), not marked as ignorable

      这是因为我们user类中一些字段在数据库中是没有的,需要在类上加上一个注解:@JsonIgnoreProperties(ignoreUnknown = true)

      解决方案来源于这个博客:jackson json转bean忽略没有的字段 not marked as ignorable

    2. com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.security.core.GrantedAuthority

      这是因为我们在构造JWT Token时候是将整个user对象写入的,user类中getAuthorities方法导致的,需要在getAuthorities方法上加上一个注解@JsonDeserialize(using = CustomAuthorityDeserializer.class)并定义一个类:CustomAuthorityDeserializer

      具体解决方式可以参考这个博文:Cannot construct instance of org.springframework.security.core.GrantedAuthority的错误解决

    UserDetailsService:

    @Service
    public class UserService implements UserDetailsService {
    
        @Autowired
        private UserMapper userMapper;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            User user = userMapper.findUserByUsername(username);
            if (user == null) {
                throw new UsernameNotFoundException("User: " + username + " not exist!");
            }
    
            return user;
        }
    }
    

    Provider通过调用UserService的方法loadUserByUsername去认证用户,这里的方法里通过用户名查询数据库。

    当然这个UserService是我们自定义的实现UserDetailsService的,在后面我们的Security配置中要设置为这个Service

    数据库操作相关的比较简单,具体再看gitee上的源码吧,哈哈。

  3. JWT工具类

    public class JWTUtil {
        // 密钥
        private final static String secretKey = "Lucas";
    
        // 过期时间设置为5min
        private final static Duration expiration = Duration.ofMinutes(10);
    
        /**
         * 生成Token
         * @param user
         * @return
         */
        public static String generate(User user) {
            // 过期时间
            Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis());
            try {
                return Jwts.builder()
                    .setSubject(new ObjectMapper().writeValueAsString(user))       // 将username放进JWT
                    .setIssuedAt(new Date())    // 设置jwt签发时间
                    .setExpiration(expiryDate)  // 设置jwt过期时间
                    .signWith(SignatureAlgorithm.HS512, secretKey)  // 设置加密算法和密钥
                    .compact();
            } catch (Exception e) {
                return null;
            }
        }
    
        /**
         * 解析Token
         * @param token
         * @return
         */
        public static Claims parse(String token) {
            try {
                return Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody();
    
            } catch (Exception e) {
                return null;
            }
        }
    
    }
    

    这里只是简单的构造,一个生成Token的方法,一个解析Token的方法,密钥设置为字符串"Lucas", 过期时间设置为10分钟。

    更加细致的工具方法暂未去实现,先凑合着用着。

    其实,到了这里我们就差将我们自定义的Filter和Provider添加到SpringSecurity的主配置中,就实现了认证了,但我们这次用的是JWT方式,服务器端并没有记录登录用户的信息,因此我们需要多一个过滤器,用来拦截请求,验证Token的,如果请求携带了Token,并且token里的信息是正确的,我们才将Authentication设置已认证,让SpringSecurity过滤器链去做后续的鉴权啥的。

  4. 自定义Token拦截器

    SpringSecurity默认的基础配置中没有提供对Bearer Authentication处理的过滤器,但是提供了处理Basic Authentication的过滤器BasicAuthenticationFilter,我们还是可以模仿它实现自己的Filter

    public class JWTAuthenticationRequestFilter extends OncePerRequestFilter {
    
        private UserDetailsService userDetailsService;
    
        public JWTAuthenticationRequestFilter(UserDetailsService userDetailsService) {
            this.userDetailsService = userDetailsService;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // 校验Token
            Authentication authentication = getAuthentication(request);
            if (authentication == null) {
                filterChain.doFilter(request, response);
                return;
            }
            // 认证成功, 保存到SecurityContext上下文中
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        }
    
        private Authentication getAuthentication(HttpServletRequest request) throws JsonProcessingException {
            // 判断是否有Token,拿到Token
            String header = request.getHeader("Authorization");
            if (header == null || !header.startsWith("Bearer ")) {
                return null;
            }
            String token = header.split(" ")[1];
            System.out.println("token = " + token);
            // 通过Token解析
            Claims claims = JWTUtil.parse(token);
            if (claims == null) {
                return null;
            }
            User user = (User) new ObjectMapper().readValue(claims.getSubject(), User.class);
    
            // 开始验证
            // 通过用户名查找数据库,确认是否存在该用户
            UserDetails userDetails = null;
            try {
                userDetails = userDetailsService.loadUserByUsername(user.getUsername());
            } catch (UsernameNotFoundException e) {
                return null;
            }
    
            // 校验Token的密码与数据库中用户密码是否一致
            if (!userDetails.getPassword().equals(user.getPassword())) {
                return null;
            }
            // 构造Authentication对象
            return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
        }
    }
    

    简单说明下这里的逻辑:

    1. 对于任意的请求,都会被该过滤器拦截,首先会判断是否携带了token,这里设置是请求头Authentication中放置Token
    2. 接着对Token进行解析,拿出我们存放进去的user类对象
    3. 调用userService调用loadUserByUsername去查询数据库,确定是否存在该用户
    4. 校验数据库中的用户密码是否与Token中的用户密码信息一致,如果一致则构造一个可信的Authentication对象,这里是UsernamepasswordAuthenticationToken;如果不一致,则返回null
    5. 若Token校验不通过,则放行,SpringSecurity后续的过滤器会处理掉他的。
    6. 若Token校验成功,则将校验对象Authentication放入SpringContext上下文中
  5. 自定义SpringSecurity中的异常

    前面一些处理报异常时并没有返回到客户端,我们还需要自定义SpringSecurity的全局异常处理,返回JSON数据给到客户端。

    具体的操作可以学习小胖哥这篇博文:Spring Security 实战干货:自定义异常处理

    权限相关异常处理:

    public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
    
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpServletResponse.SC_FORBIDDEN);
            myResponse.setMsg("没有权限!");
    
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse));
        }
    }
    

    认证相关异常处理:

    public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpServletResponse.SC_UNAUTHORIZED);
            myResponse.setMsg(authException.getMessage());
    
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse));
        }
    }
    
  6. 配置

    我们还是自定义一个配置类继承SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>,配置我们自定义的Filter

    @Component
    public class JWTLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    
        @Autowired
        private UserService userService;
    
        @Override
        public void configure(HttpSecurity http) throws Exception {
            // 配置Filter
            JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter("/jwt/login");
            JWTAuthenticationRequestFilter jwtAuthenticationRequestFilter = new JWTAuthenticationRequestFilter(userService);
    
            // 配置AuthenticationManager
            jwtAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
    
            // 添加Filter
            http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
            http.addFilterBefore(jwtAuthenticationRequestFilter, UsernamePasswordAuthenticationFilter.class);
        }
    }
    

    注意的点:

    1. 这里虽说是一个配置类,但是最后是要并入SpringSecurity的主配置的,这里用的注解是@Component,能让我们在主配置类中进行注入。
    2. 自定义的认证Filter一定要设置AuthenticationManager,否则找不到对应的Provider来处理。

    主配置类:

    @Configuration
    public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private JWTLoginConfig jwtLoginConfig;
    
        @Autowired
        private UserService userService;
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        public void configure(AuthenticationManagerBuilder builder) throws Exception {
            builder.userDetailsService(userService).passwordEncoder(passwordEncoder());
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                .antMatchers("/hello")
                .hasRole("ROOT")
                .anyRequest()
                .authenticated()
                .and()
                .apply(jwtLoginConfig)
                .and()
                .csrf()
                .disable();
    
            // 自定义异常处理
            http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());
            // 前后端分离是STATELESS,session使用该策略
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        }
    }
    

    这里我们自定义了UserDetailService方法,因此需要配置上builder.userDetailsService(userService).passwordEncoder(passwordEncoder());,密码编码器不可少。

    然后自定义异常的话就得使用http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());将我们自定义的类传入

    最后是将session策略设置为了STATELESS.

5. 测试

我简单写了一个接口/hello

@RestController
public class UserController {

    @GetMapping("/hello")
    public String hello() {
        return "hello jwt!";
    }
}

通过Postman进行测试验证:

  1. 访问/jwt/login登录

    返回了一个Token,拿着这个Token去做请求

  2. 访问/hello

    在发起请求时,在header部分添加一个key为Authentication,value为登录请求时返回的Token,才能正常访问,获得回复。

  3. 当Token过期时,再去访问/hello

    返回了401的错误,这是咱们自定义的异常处理器SimpleAuthenticationEntryPoint返回了信息

  4. 我们在定义User类时默认写死了权限为ROLE_ROOT,那当我们将/hello的访问权限修改为ROLE_USER时去访问

    http.authorizeRequests().antMatchers("/hello").hasRole("USER")
    

返回了403错误,这是自定义异常处理器SimpleAccessDeniedHandler返回的信息

6. JWT存在的问题

  1. 续签问题,传统的cookie+session的方案天然支持续签,但是jwt由于服务端不保存用户状态了,因此很难完美解决续签问题
  2. 注销问题,因为服务器不保存用户信息,所以一般可以通过修改secret来实现注销,但会有个问题,服务端的secret修改后,已经颁发的还没过期的token就会认证失败。
  3. 一般建议不同用户取不同的secret
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容