Spring Security 6.0 - Token下发

前言

几年前写过一篇 Spring Security 相关博文:Spring Boot - 集成 Spring Security,基于 5.0 版本。而当前 Spring Security 最新稳定版本为 Spring Security 6.2.0,相较于 5.0 版本,6.0 版本的 Spring Security 引入了很多破坏性更新,比如对一些类进行了移除,方法重命名,采用DSL配置,废弃了一些方法...,因此,那篇博文中的很多配置已不能生效了。

Token下发

当前 Spring Security 最新稳定版本为 Spring Security 6.2.0,相较于 5.0 版本,6.0 版本的 Spring Security 引入了很多破坏性更新,比如对一些类进行了移除,方法重命名,采用DSL配置,废弃了一些方法...,因此,本文中的很多配置已不能生效了。

这里采用最新 Spring Secuirty 6+,对 Token下发 给出最新示例配置。

前文介绍过,Spring Security 默认登录接口为/login,默认是由UsernamePasswordAuthenticationFilter进行表单登录认证,我们前面也是通过自定义UsernamePasswordAuthenticationFilter实现 JSON登录认证。不过,此处我们进行简化,不再用 Spring Security 默认的登录接口和逻辑,而是通过自定义注册和登录接口(/signup & /signin)实现登录认证,然后通过自定义一个 JSON Web Token 过滤器(JwtTokenAuthenticationFilter),进行 Token 验证,实现用户认证。具体步骤如下:

  1. 前期配置:在正式进行 Sprng Security 配置前,先将前期环境配置一下,包含下面几方面:

    • 测试接口:添加测试接口,模拟真实业务接口:

      @RestController
      @RequestMapping
      public class TestApi {
      
          // 测试
          @GetMapping("/test")
          public String index() {
              return "hello world!";
          }
      
          // 获取当前用户信息
          @GetMapping("/user")
          public String whoami() {
              Authentication auth = SecurityContextHolder.getContext().getAuthentication();
              String name = auth.getName();
              Object principal = auth.getPrincipal();
              String password = (String) auth.getCredentials();
              Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
              HttpServletRequest request = (HttpServletRequest) auth.getDetails();
      
              StringBuilder builder = new StringBuilder();
              builder.append(String.format("name: %s\n", name));
              builder.append(String.format("principal: %s\n", principal));
              builder.append(String.format("password: %s\n", password));
              builder.append(String.format("authorities: %s\n", authorities.stream()
                      .map(GrantedAuthority::getAuthority)
                      .collect(Collectors.joining(",")))
              );
              builder.append(String.format("getDetails: %s\n", request));
              return builder.toString();
          }
      }
      
    • 用户数据库:这里我们使用真实的数据库,用户表如下所示:

      -- create tbale tb_user
      create table `tb_user` (
          `id` bigint unique not null auto_increment,
          `name` varchar(30) unique not null comment 'user name',
          `password` varchar(100) not null comment 'user password',
          `role` enum('admin','normal','anonymous') default 'anonymous' comment 'user role',
          `authority` set('create','read','update','delete') comment 'user authorities',
      
          primary key(`id`)
      );
      
    • 数据库相关配置

      • 导入相关依赖:
        <!-- pom.xml -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>
        
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        
      • 设置相关配置:
        spring:
          datasource:
            driver-class-name: com.mysql.cj.jdbc.Driver
            url: jdbc:mysql://127.0.0.1:3306/whyn
            username: root
            password: 123456
        
        mybatis:
          mapper-locations: classpath:mapper/**/*.xml
        
    • 用户表操作:采用 MyBatis

      • 实体类:这里实体类User实现了 Spring Security 的UserDetails,表示用户信息:

        @Data
        @AllArgsConstructor
        @NoArgsConstructor
        @Builder
        public class User implements UserDetails {
            private Long id;
            private String name;
            private String password;
            private String role;
            private String authority;
        
            @Override
            public Collection<? extends GrantedAuthority> getAuthorities() {
                String[] authorities = this.authority.split(",");
                String rolePrefix = "ROLE_";
                String roleAuthority = this.role;
                // 将 role 转成 ROLE_XXX
                if (null != roleAuthority && !roleAuthority.startsWith(rolePrefix)) {
                    roleAuthority = (rolePrefix + roleAuthority).toUpperCase();
                }
        
                return Stream.concat(
                                Arrays.stream(authorities),
                                Stream.of(roleAuthority)
                        ).filter(Predicate.not(String::isBlank))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());
            }
        
            // UserDetailsService#loadUserByUsername(username)
            // 其中的 username 就是 getUsername,本质是一个唯一的标识,此处可使用其他唯一性字段进行代替
            @Override
            public String getUsername() {
                return this.name;
            }
        
            @Override
            public String getPassword() {
                return this.password;
            }
        
        
            @Override
            public boolean isAccountNonExpired() {
                return true;
            }
        
            @Override
            public boolean isAccountNonLocked() {
                return true;
            }
        
            @Override
            public boolean isCredentialsNonExpired() {
                return true;
            }
        
            @Override
            public boolean isEnabled() {
                return true;
            }
        }
        
      • 用户表操作类:

        @Mapper
        public interface IUserDao {
            @Insert("insert into tb_user values( #{ id }, #{ name }, #{ password }, #{ role }, #{ authority })")
            int insert(User user);
            @Select("select * from tb_user where id = #{id}")
            User selectOneByPrimaryKey(Long id);
            @Select("select * from tb_user where name=#{ name }")
            User selectOneByName(String name);
        }
        
      • 用户表服务类:

        // IUserService.java
        public interface IUserService {
            User findUser(String name);
        }
        
        // UserServiceImpl.java
        @Service
        @AllArgsConstructor
        public class UserServiceImpl implements IUserService {
            private final IUserDao userDao;
            @Override
            public User findUser(String name) {
                return this.userDao.selectOneByName(name);
            }
        }
        

    至此,前期准备工作已完成,可以开始配置 Spring Security 相关内容。

  2. 引入相关依赖

    <!-- 导入 Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
    <!-- 导入 jjwt -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.3</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    
  3. JWT工具类:配置过程如下:

    • 配置 JWT 相关信息:

      # application.yml
      jwt:
        token:
          # 密钥
          secret-key: "this_is_private_secret_key"
          # 过期时间 7 天
          # jshell> TimeUnit.DAYS.toMillis(7);
          # $1 ==> 604800000
          expiration: 604800000
          # token 前缀
          token-prefix: Bearer
      
    • 抽取一个 JWT 工具类:

      @Service
      public class JwtTokenService {
          public static final String KEY_USER_NAME = "username";
          public static final String KEY_USER_AUTHORITIES = "authorities";
      
          @Value("${jwt.token.secret-key}")
          private String secretKey;
          @Value("${jwt.token.expiration}")
          private long expiration;
          @Value("${jwt.token.token-prefix}")
          private String tokenPrefix;
      
          public String getTokenPrefix() {
              return this.tokenPrefix;
          }
      
          // 解析 token
          public Map<String, Object> parseToken(String token) {
              Map<String, Object> userDetails = new HashMap<>();
              try {
                  token = validateToken(token);
                  Jws<Claims> claimsJws = Jwts.parser()
                          .setSigningKey(this.getSecretKey()).build().parseClaimsJws(token);
                  // 用户名
                  String username = claimsJws.getBody().getSubject();
                  userDetails.put(KEY_USER_NAME, username);
                  // 用户权限
                  List<Map<String, String>> authorities = (List<Map<String, String>>) claimsJws.getBody().get(KEY_USER_AUTHORITIES);
                  if (null != authorities) {
                      Collection<? extends GrantedAuthority> userAuthorities = authorities.stream()
                              .map(item ->
                                      new SimpleGrantedAuthority(item.get("authority")))
                              .collect(Collectors.toSet());
                      userDetails.put(KEY_USER_AUTHORITIES, userAuthorities);
                  }
                  return userDetails;
              } catch (JwtException e) {
                  throw new IllegalStateException(String.format("invalid token: %s", token));
              }
          }
      
          // 生成token
          public String generateToken(String username) {
              String token = Jwts.builder()
                      .setSubject(username)
                      .setIssuedAt(new Date())
                      .setExpiration(new Date(System.currentTimeMillis() + this.expiration))
                      .signWith(this.getSecretKey())
                      .compact();
              return generateTokenWithPrefix(token);
          }
      
          // 生成 token,包含用户主体和其权限
          public String generateToken(String username, Object authorities) {
              String token = Jwts.builder()
                      // 用户名
                      .setSubject(username)
                      // payload
                      .claim(KEY_USER_AUTHORITIES, authorities)
                      // 发行时间
                      .setIssuedAt(new Date())
                      // 过期时间
                      .setExpiration(new Date(System.currentTimeMillis() + this.expiration))
                      // 私钥
                      .signWith(getSecretKey())
                      .compact();
              return generateTokenWithPrefix(token);
          }
      
          // token 添加前缀 Bearer
          private String generateTokenWithPrefix(final String token) {
              return String.format("%s %s", this.tokenPrefix, token);
          }
      
          // 生成签名私钥
          private Key getSecretKey() {
              return Keys.hmacShaKeyFor(generateSecretKey());
          }
      
          // 加密要求至少 256 位,因此将私钥进行 sha256,只是单纯为了生存 256 个字节
          private byte[] generateSecretKey() {
              byte[] hashKey = null;
              String secretKey = this.secretKey;
              try {
                  MessageDigest digest = MessageDigest.getInstance("SHA-256");
                  hashKey = digest.digest(secretKey.getBytes(StandardCharsets.UTF_8));
              } catch (NoSuchAlgorithmException e) {
                  hashKey = this.fillBytes(secretKey);
              }
              return hashKey;
          }
      
          // 循环字符串添加到 256 个字节
          private byte[] fillBytes(String str) {
              if (str == null) {
                  throw new IllegalArgumentException("secret key must not be null!");
              }
              byte[] bytes256 = new byte[256];
              int length = str.length();
              for (int i = 0; i < 256; ++i) {
                  // 忽视精度缺失,只是为了添加到 256 个字节
                  bytes256[i] = (byte) str.charAt(i % length);
              }
              return bytes256;
          }
      
          // 去除 token 前缀:Bearer
          private String validateToken(String token) {
              String rawToken = token;
              String tokenPrefix = this.tokenPrefix;
              if (rawToken.startsWith(tokenPrefix)) {
                  rawToken = rawToken.substring(tokenPrefix.length()).trim();
              }
              return rawToken;
          }
      }
      
  4. 用户登录认证配置:Spring Security 对于用户登录有一套完整的认证流程,比如我们常见的表单登录认证,它是由UsernamePasswordAuthenticationFilter负责处理的,整个认证流程如下图所示:

    spring-security-authentication-process

    :图片来源于互联网,侵删

    简单来说,认证过程是通过AuthenticationManager#authenticate开启认证,然后经由AuthenticationProvider#authenticate,然后从与其绑定的UserDetailsService#loadUserByUsername获取到真实的用户信息UserDetails,如此,AuthenticationProvider就可以比对前端传递过来的用户密码与数据库中该用户密码是否匹配,匹配则验证成功,最后会构建一个新的Authentication对象,保存用户相关信息,并放置到SecurityContextHolder的上下中,供后续组件获取该用户信息。

    因此,对于用户认证流程,我们这里需要配置以上相关组件,如下所示:

    @Configuration
    @EnableWebSecurity
    @AllArgsConstructor
    public class SecurityConfiguration {
    
        private final IUserService userService;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            // 手动关联我们设置的 AuthenticationManager(可选,默认注册已关联)
            http.authenticationManager(this.authenticationManager());
            return http.build();
        }
    
        // 密码加密器
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        // 获取用户及其相关详细信息
        @Bean
        public UserDetailsService userDetailsService() {
            return new UserDetailsService() {
                @Override
                public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                    User user = userService.findUser(username);
                    if (null == user) {
                        throw new UsernameNotFoundException(String.format("[username: %s] not found!", username));
                    }
                    return user;
                }
            };
        }
    
        // 负责具体用户认证流程
        @Bean
        public AuthenticationProvider authenticationProvider() {
            // 创建一个用户认证提供者
            DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
            // 将该 Provider 关联到我们设置的 UserDetailsService
            authProvider.setUserDetailsService(this.userDetailsService());
            // 关联到我们设置的加密算法
            authProvider.setPasswordEncoder(this.passwordEncoder());
            return authProvider;
        }
    
        // 认证管理者
        @Bean
        public AuthenticationManager authenticationManager() {
            // 关联到我们设置的 AuthenticationProvider
            return new ProviderManager(this.authenticationProvider());
        } 
    }
    
  5. 自定义注册和登录接口

    • Controller

      @RestController
      @RequestMapping("/auth")
      public class AuthApi {
      
          @Autowired
          private IAuthService authService;
      
          // 用户注册接口
          @PostMapping("/signup")
          public boolean signUp(@RequestBody User user) {
              return this.authService.signUp(user);
          }
      
          // 用户登录接口
          @PostMapping("/signin")
          public void signIn(@RequestBody User user, HttpServletResponse response) {
              String jwtToken = this.authService.signIn(user);
              Optional.ofNullable(jwtToken)
                      .ifPresentOrElse(token -> {
                          this.success(response, token);
                      }, () -> {
                          this.failed(response);
                      });
          }
      
          private void success(HttpServletResponse response, String jwtToken) {
              response.addHeader(HttpHeaders.AUTHORIZATION, jwtToken);
              response.setCharacterEncoding("utf-8");
              response.setContentType("application/json");
              try (PrintWriter writer = response.getWriter()) {
                  writer.print("login successfully!");
              } catch (IOException e) {
                  throw new RuntimeException(e);
              }
          }
      
          private void failed(HttpServletResponse response) {
              response.setStatus(HttpStatus.UNAUTHORIZED.value());
              try (PrintWriter writer = response.getWriter()) {
                  writer.print("login failed! username or password incorrect");
              } catch (IOException e) {
                  throw new RuntimeException(e);
              }
      
          }
      }
      
    • Service:

      // IAuthService.java
      public interface IAuthService {
      
          boolean signUp(User user);
      
          String signIn(User user);
      }
      
      // AuthServiceImpl.java
      @Service
      @AllArgsConstructor
      public class AuthServiceImpl implements IAuthService {
      
          private final IUserDao userDao;
          private final JwtTokenService jwtTokenService;
          private final AuthenticationManager authManager;
          private final PasswordEncoder passwordEncoder;
      
          @Override
          public boolean signUp(User user) {
              String encodePassword = this.passwordEncoder.encode(user.getPassword());
              user.setPassword(encodePassword);
              return this.userDao.insert(user) > 0;
          }
      
          @Override
          public String signIn(User user) {
              String jwtToken = null;
              try {
                  String username = user.getUsername();
                  String password = user.getPassword();
                  if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
                      throw new AuthenticationServiceException("username & password must not be null");
                  }
                  // 构造一个 Authentication 对象,设置 principal & credentials
                  // principal 意为主要的,对应数据库中唯一字段(unique key),此处设置为 username,
                  // 若进行更改,则相应的 userDetails#getUsername() 和 UserDetailsService#loadUserByUsername(String username)
                  // 都要设置为对同一字段进行操作
                  // credentials 就是指代密码
                  Authentication authentication = new UsernamePasswordAuthenticationToken(username, password);
                  // 可自定义填充其余信息,方便后续获取用户时,能获取这些自定义信息
                  // ((UsernamePasswordAuthenticationToken)authentication).setDetails(null);
      
                  // AuthenticationManager 进行认证,认证失败抛异常
                  // 认证通过,成功返回一个新的 Authentication 对象,其内包含有用户所有信息(包含上面自定义信息 setDetails),只是将密码去除
                  Authentication successDetailedAuth = this.authManager.authenticate(authentication);
                  // 认证通过
                  // 获取用户详细信息
                  Collection<? extends GrantedAuthority> authorities = successDetailedAuth.getAuthorities();
                  // 下发 jwt token
                  jwtToken = this.jwtTokenService.generateToken(username, authorities);
      
              } catch (AuthenticationException e) {
                  e.printStackTrace();
              }
              return jwtToken;
          }
      }
      

    至此,登录和注册功能就完成了。每次登录时,成功后会在响应头中携带上一串 Jwt Token,后续请求都需要携带该 token,后端对该 token 验证成功,才允许其访问相应资源。

  6. 设置 Jwt Token 验证过滤器

    @AllArgsConstructor
    public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
    
        private final JwtTokenService jwtTokenService;
        private final UserDetailsService userDetailsService;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain filterChain) throws ServletException, IOException {
            try {
                String jwtToken = request.getHeader(HttpHeaders.AUTHORIZATION);
                if (null != jwtToken && jwtToken.startsWith(this.jwtTokenService.getTokenPrefix())) {
                    // 解析 jwt token,失败抛异常
                    Map<String, Object> userDetailsMap = this.jwtTokenService.parseToken(jwtToken);
                    // 认证通过,从 token 中提取出 username
                    String username = (String) userDetailsMap.get(JwtTokenService.KEY_USER_NAME);
                    // 获取用户详细信息
                    UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                    // 构建一个 Authentication 认证对象,填入用户相关信息
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                            username,     // principal 用户名
                            null, // credentials 密码敏感数据,直接置为空即可
                            userDetails.getAuthorities());
                    // 附加详细信息,比如请求体,有些认证方式需要除了用户名密码外更多的信息
                    // 后续可通过 Authentication#getDetails() 获取自定义的额外信息
                    authentication.setDetails(request);
                    // 认证成功,直接设置到 SecurityContextHolder 中,供后续 Filters 使用
                    // 该操作会将 Authentication 存放到 ThreadLocal 中,这样当前请求在后续操作中就能获取到该 Authentication
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            filterChain.doFilter(request, response);
        }
    }
    

    当 Jwt token 解析成功时,则表示验证通过,最后会将当前用户相关信息包裹在一个Authentication对象中,并将该对象放置在全局SecurityContextHolder中,方便后续组件获取当前用户信息。

  7. 配置一个 SecurityFilterChain:配置一个SecurityFilterChain,关联我们自定义的JwtTokenAuthenticationFilter,让其生效;同时配置其他相关信息:

    @Configuration
    @EnableWebSecurity
    @AllArgsConstructor
    public class SecurityConfiguration {
    
        private final IUserService userService;
        private final JwtTokenService jwtTokenService;
    
        // 
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http.csrf(AbstractHttpConfigurer::disable)
                    .cors(Customizer.withDefaults())
                    .sessionManagement(sessionManager -> sessionManager.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                    .authenticationManager(this.authenticationManager())
                    // 配置 JwtTokenAuthenticationFilter
                    .addFilterBefore(new JwtTokenAuthenticationFilter(
                                    this.jwtTokenService,
                                    this.userDetailsService()),
                            UsernamePasswordAuthenticationFilter.class)
                    // 请求认证授权
                    .authorizeHttpRequests(requests -> {
                        // 放行 POST 请求接口 /auth/signin,/auth/signup
                        requests.requestMatchers(HttpMethod.POST, "/auth/signin", "/auth/signup").permitAll()
                                // 放行 /test/** 接口所有请求
                                .requestMatchers("/test/**").permitAll()
                                // 其余请求,一律需要进行认证
                                .anyRequest().authenticated();
                    });
    
            return http.build();
    
        }
        // ...
    }
    

以上,我们便完成了 Spring Security 6+ 版本对 Token下发 功能的一个配置。

我们可以模拟一个用户注册,登录,访问完整逻辑,测试如下:

# 注册用户:admin
$ curl -X POST 'localhost:8080/auth/signup' --header 'Content-Type: application/json; charset=utf-8' --data '{"name":"admin", "password":"admin_password", "role": "admin", "authority": "create,read,update,delete"}' 
true% 

# 登录用户:admin
$ curl -X POST 'localhost:8080/auth/signin' --header 'Content-Type: application/json; charset=utf-8' --data '{"name":"admin", "password":"admin_password"}' -v
# 可以看到,登录成功后会返回一个 Jwt token
< Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJjcmVhdGUifSx7ImF1dGhvcml0eSI6InJlYWQifSx7ImF1dGhvcml0eSI6InVwZGF0ZSJ9LHsiYXV0aG9yaXR5IjoiZGVsZXRlIn0seyJhdXRob3JpdHkiOiJST0xFX0FETUlOIn1dLCJpYXQiOjE3MDI2MzQ3MTEsImV4cCI6MTcwMzIzOTUxMX0.RpyqdBlmbfWsLR1M6mb7SH9RPpFgJJODiZ1mIvx8T5Y
login successfully!% 

# 访问资源
 $ curl -X GET 'localhost:8080/user' --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJjcmVhdGUifSx7ImF1dGhvcml0eSI6InJlYWQifSx7ImF1dGhvcml0eSI6InVwZGF0ZSJ9LHsiYXV0aG9yaXR5IjoiZGVsZXRlIn0seyJhdXRob3JpdHkiOiJST0xFX0FETUlOIn1dLCJpYXQiOjE3MDI2MzQ3MTEsImV4cCI6MTcwMzIzOTUxMX0.RpyqdBlmbfWsLR1M6mb7SH9RPpFgJJODiZ1mIvx8T5Y'
name: admin
principal: admin
password: null
authorities: create,read,update,delete,ROLE_ADMIN
getDetails: org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@60e25235

完整源码可查看:spring-security-demo

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

推荐阅读更多精彩内容