spring security oauth2 认证端异常处理(基于前后分离统一返回json)

在默认的情况下,spring security oauth2 在发生异常情况下,返回的格式并不符合现实需要,其格式是:

{
    "error": "invalid_grant",
    "error_description": "Bad credentials"
}

我们一般需要是这种格式

{
    "code": "-1",
    "msg": "Bad credentials"
}

认证端

对于oauth2的异常,其类主要是 OAuth2Exception ,默认处理这些异常的是DefaultWebResponseExceptionTranslator,其错误主要是包含oauth2相关的错误,就类似以下的。

@SuppressWarnings("serial")
@org.codehaus.jackson.map.annotate.JsonSerialize(using = OAuth2ExceptionJackson1Serializer.class)
@org.codehaus.jackson.map.annotate.JsonDeserialize(using = OAuth2ExceptionJackson1Deserializer.class)
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = OAuth2ExceptionJackson2Serializer.class)
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = OAuth2ExceptionJackson2Deserializer.class)
public class OAuth2Exception extends RuntimeException {

    public static final String ERROR = "error";
    public static final String DESCRIPTION = "error_description";
    public static final String URI = "error_uri";
    public static final String INVALID_REQUEST = "invalid_request";
    public static final String INVALID_CLIENT = "invalid_client";
    public static final String INVALID_GRANT = "invalid_grant";
    public static final String UNAUTHORIZED_CLIENT = "unauthorized_client";
    public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
    public static final String INVALID_SCOPE = "invalid_scope";
    public static final String INSUFFICIENT_SCOPE = "insufficient_scope";
    public static final String INVALID_TOKEN = "invalid_token";
    public static final String REDIRECT_URI_MISMATCH ="redirect_uri_mismatch";
    public static final String UNSUPPORTED_RESPONSE_TYPE ="unsupported_response_type";
    public static final String ACCESS_DENIED = "access_denied";

DefaultWebResponseExceptionTranslator处理的OAuth2Exception,其类可见的序列化器如下,可以得出默认的返回格式:

DefaultWebResponseExceptionTranslator类中:

private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {

        int status = e.getHttpErrorCode();
        HttpHeaders headers = new HttpHeaders();
        headers.set("Cache-Control", "no-store");
        headers.set("Pragma", "no-cache");
        if (status == HttpStatus.UNAUTHORIZED.value() || (e instanceof InsufficientScopeException)) {
            headers.set("WWW-Authenticate", String.format("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary()));
        }

        ResponseEntity<OAuth2Exception> response = new ResponseEntity<OAuth2Exception>(e, headers,
                HttpStatus.valueOf(status));

        return response;

    }

·····························································································································································
OAuth2Exception的序列化器:

public class OAuth2ExceptionJackson2Serializer extends StdSerializer<OAuth2Exception> {

    public OAuth2ExceptionJackson2Serializer() {
        super(OAuth2Exception.class);
    }

    @Override
    public void serialize(OAuth2Exception value, JsonGenerator jgen, SerializerProvider provider) throws IOException,
            JsonProcessingException {
        jgen.writeStartObject();
        jgen.writeStringField("error", value.getOAuth2ErrorCode());
        String errorMessage = value.getMessage();
        if (errorMessage != null) {
            errorMessage = HtmlUtils.htmlEscape(errorMessage);
        }
        jgen.writeStringField("error_description", errorMessage);
        if (value.getAdditionalInformation()!=null) {
            for (Entry<String, String> entry : value.getAdditionalInformation().entrySet()) {
                String key = entry.getKey();
                String add = entry.getValue();
                jgen.writeStringField(key, add);                
            }
        }
        jgen.writeEndObject();
    }

}

所以我们需要重新定义自己的WebResponseExceptionTranslator,以及自己的oauth2异常以及其序列化器(也可以不定义,重写handleOauth2Exception即可)

自定义oauth2异常处理类,CustomWebResponseExceptionTranslator,模仿DefaultWebResponseExceptionTranslator即可,下面给出关键部分,注意result是自己定义返回格式的bean,含有msg和code。最后在认证服务器配置一下就可。

    private ResponseEntity handleOAuth2Exception(OAuth2Exception e) throws IOException {

        log.info("occur exception : " , e);

        int status = e.getHttpErrorCode();
        HttpHeaders headers = new HttpHeaders();
        headers.set("Cache-Control", "no-store");
        headers.set("Pragma", "no-cache");
        if (status == HttpStatus.UNAUTHORIZED.value() || (e instanceof InsufficientScopeException)) {
            headers.set("WWW-Authenticate", String.format("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary()));
        }

        Oauth2ExceptionCodeEnum oauth2ExceptionCodeEnum = Oauth2ExceptionCodeEnum.UNAUTHORIZED_CLIENT;

        if (e instanceof InvalidScopeException){
            oauth2ExceptionCodeEnum = Oauth2ExceptionCodeEnum.INVALID_SCOPE;
        }
        if (e instanceof InvalidGrantException){
            oauth2ExceptionCodeEnum = Oauth2ExceptionCodeEnum.INVALID_GRANT;
        }
        if (e instanceof InvalidClientException){
            oauth2ExceptionCodeEnum = Oauth2ExceptionCodeEnum.INVALID_CLIENT;
        }
        if (e instanceof UnsupportedGrantTypeException){
            oauth2ExceptionCodeEnum = Oauth2ExceptionCodeEnum.UNSUPPORTED_GRANT_TYPE;
        }

        if (e instanceof RedirectMismatchException){
            oauth2ExceptionCodeEnum = Oauth2ExceptionCodeEnum.REDIRECT_URI_MISMATCH;
        }

        Result r = new Result(oauth2ExceptionCodeEnum.getMsg(),oauth2ExceptionCodeEnum.getCode());

        return new ResponseEntity<>(r, headers, HttpStatus.valueOf(status));

    }

@Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //MUST:密码模式下需设置一个AuthenticationManager对象,获取 UserDetails信息
        //末确认点.userDetailsService(userDetailsService)

        /*
          使用 /pig4cloud/login 覆盖 原有的/oauth/token,注意这里是覆盖一旦配置 原有路径将失效
          endpoints.pathMapping("/oauth/token","/pig4cloud/login");
        */

        endpoints.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
        endpoints.approvalStore(jdbcApprovalStore());
        endpoints.authorizationCodeServices(jdbcAuthorizationCodeServices());
        /*
          这个是处理oauth2那些grant_type、invalid code 之类的异常返回,但是clientId、clientSecret这些错它不管的,
          要靠上面上面的security来配置security.authenticationEntryPoint()
         */
        endpoints.exceptionTranslator(new CustomWebResponseExceptionTranslator());
         /* 有了tokenServices就不设置了
        endpoints.authenticationManager(authenticationManager);

        endpoints.setClientDetailsService(jdbcClientDetailsService());
        endpoints.tokenStore(new JdbcTokenStore(dataSource));


        //token增强器,多加点信息在里面
        endpoints.tokenEnhancer(tokenEnhancerChain());
        */

        //tokenServices没有的话会自动创建一个默认的
        endpoints.tokenServices(customTokenService());
    }

这里说的是oauth2的异常处理,其中还有认证的异常,我们这里使用basic的认证方式,如果发生异常,是不会走这个自定义的异常处理器的,所以在securityConfig中配置,在httpBasic后面配置的那里处理了。

    @Override
    protected void configure(HttpSecurity http) throws Exception {
       http.addFilterBefore(customBasicAuthenticationFilter, BasicAuthenticationFilter.class);

        http.authorizeRequests()
                .antMatchers("/oauthClient/**").permitAll()
                .anyRequest().authenticated()
                .and()
                //授权码模式得加上httpBasic 不然报403,前后分离就用basic,不然用formLogin也开httpBasic在basic之前加filter
                /*
                设置好authentication认证好再SecurityContextHolder.getContext().setAuthentication(authResult);,不然触发
                .exceptionHandling().authenticationEntryPoint 产生 InsufficientAuthenticationException异常。说还没认证身份;

                要不就直接省功夫用basic认证,
                demoUri: http://kalos:123456789@127.0.0.1:8547/oauth/authorize?client_id=ncsf6sb4&response_type=code&scope=user_info&state=orion
                */
                .httpBasic()
                //密码错误返回在这里配置,用户名在自定义userDetail里配置
                .authenticationEntryPoint((request, response, authException) -> {
                    System.out.println("basic commence AuthenticationException:" + authException);
                    Result r = Oauth2ExceptionCodeEnum.UNAUTHORIZED_CLIENT.toResult();
                    ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
                })
                .and()
                .exceptionHandling()
                .authenticationEntryPoint((request, response, authException) -> {
                    System.out.println("security config commence AuthenticationException:" + authException.getMessage());
                    Result r = CommonCodeEnum.COMMON_SERVER_ERROR.toResult();
                    ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    System.out.println("security config commence accessDeniedException:" + accessDeniedException.getMessage());
                    Result r = Oauth2ExceptionCodeEnum.ACCESS_DENIED.toResult();
                    ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
                })
                .and().csrf().disable();

                /* session 设置为 IF_REQUIRED 有需要才生成 */
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

这里加上 http.addFilterBefore(customBasicAuthenticationFilter, BasicAuthenticationFilter.class);

这个filter其实就是验证一些必要参数有无缺失,并且返回自定义的格式,

@Component
@Slf4j
public class CustomBasicAuthenticationFilter extends OncePerRequestFilter {

    private static final String OAUTH2_AUTHORIZATION_URL = "/oauth/authorize";

    @Resource
    private ClientDetailsService clientDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        if (!request.getRequestURI().equals(OAUTH2_AUTHORIZATION_URL)) {
            filterChain.doFilter(request, response);
            return;
        }

        if (!request.getMethod().equals(HttpMethod.POST.name())) {
            log.info("authorize request only post");
            Result r = Oauth2ExceptionCodeEnum.AUTHORIZE_UNSUPPORTED_REQUEST_METHOD.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        String scope = request.getParameter(OAuth2Utils.SCOPE);
        String clientId = request.getParameter(OAuth2Utils.CLIENT_ID);
        String responseType = request.getParameter(OAuth2Utils.RESPONSE_TYPE);


        if (StrUtil.isBlank(scope) || StrUtil.isBlank(clientId) || StrUtil.isBlank(responseType)) {
            log.info("missing authorize oauth2 param");
            Result r = Oauth2ExceptionCodeEnum.INVALID_REQUEST.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        Set<String> responseTypes = OAuth2Utils.parseParameterList(responseType);


        ClientDetails clientDetails;
        try {
            clientDetails = this.clientDetailsService.loadClientByClientId(clientId);
        } catch (ClientRegistrationException e) {
            log.info("clientId [{}] not found ",clientId);
            Result r = Oauth2ExceptionCodeEnum.INVALID_CLIENT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        Set<String> scopes = clientDetails.getScope();
        if (!scopes.contains(scope)){
            log.info("invalid scope [{}]",scope);
            Result r = Oauth2ExceptionCodeEnum.INVALID_SCOPE.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
            log.info("Unsupported response types: {}" , responseTypes);
            Result r = Oauth2ExceptionCodeEnum.INVALID_GRANT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        filterChain.doFilter(request,response);
    }
}

还有需要注意是:因为是授权码模式,在获取token的时候,需要传clientId和clientSecret,这是获取token的验证我们需要先于一步再到后面,所以我们会定义个过滤器处理,因为在测试过程,如果不先于验证clientId和clientSecret,返回的错误可能不是预期之中的,这里还不清楚为啥会出现那些特殊情况,所以还是先于一步先校验了、

认证服务器配置上自定义的filter,这个filter会先执行再到后面/oauth2/token链接,还有这里不开启allowFormAuthenticationForClients,还是只限于basic,如果开启了,有可能返回不正确的异常格式,具体原因不明,但后续肯定是不走basicFilter的,自定义的filter还是验证参数完整性和认证clientId和clientSecret身份是否正确。

@Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.passwordEncoder(passwordEncoder);
        // 开启/oauth/check_token验证端口认证权限访问
        security.checkTokenAccess("isAuthenticated()");
        // 开启/oauth/token_key验证端口无权限访问
        security.tokenKeyAccess("permitAll()");
        /*
         *
         * 主要是让/oauth/token支持client_id和client_secret做登陆认证
         * 如果开启了allowFormAuthenticationForClients,那么就在BasicAuthenticationFilter之前
         * 添加ClientCredentialsTokenEndpointFilter,使用ClientDetailsUserDetailsService来进行登陆认证
         *
         * 前后分离返回统一json不开启
         */
        //security.allowFormAuthenticationForClients();
        security.addTokenEndpointAuthenticationFilter(customBasicTokenFilter);
    }
@Slf4j
@Component
public class CustomBasicTokenFilter extends OncePerRequestFilter {


    /**
     * accessToken 和refreshToken 都使用该链接,只有三种模式走这个链接,隐藏式implicit只走authorize,其responseType=token
     */
    private static final String OAUTH2_AUTHORIZATION_URL = "/oauth/token";
    private static final String AUTHENTICATION_SCHEME_BASIC = "Basic";

    private static final String AUTHORIZATION_CODE = "authorization_code";
    private static final String PASSWORD = "password";
    private static final String REFRESH_TOKEN = "refresh_token";
    private static final String CLIENT_CREDENTIALS = "client_credentials";

    private static final Set<String> grantTypeSet = CollectionUtil.newHashSet(
            AUTHORIZATION_CODE,
            PASSWORD,
            REFRESH_TOKEN,
            CLIENT_CREDENTIALS);

    @Resource
    private ClientDetailsService clientDetailsService;

    @Resource
    private PasswordEncoder passwordEncoder;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        if (!request.getRequestURI().equals(OAUTH2_AUTHORIZATION_URL)) {
            filterChain.doFilter(request, response);
            return;
        }

        String grantType = request.getParameter("grant_type");
        if (StrUtil.isBlank(grantType)) {
            log.info("missing grant type");
            Result r = CommonCodeEnum.COMMON_MISSING_PARAM.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        if (!grantTypeSet.contains(grantType)) {
            log.info("invalid grant type");
            Result r = Oauth2ExceptionCodeEnum.INVALID_GRANT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        //授权码模式需要
        /*
           http://ncsf6sb4:37bc1v7l8c@127.0.0.1:8547/oauth/token?
           scope=user_info&redirect_uri=http://127.0.0.1:8549/client/receiveCode&grant_type=authorization_code&code=Vky6Ie
          //请求头放clientId和clientSecret
         */
        String scope = request.getParameter("scope");
        String redirectUri = request.getParameter("redirect_uri");
        String code = request.getParameter("code");

        //密码模式需要 password
        // http://localhost:8080/oauth/token?username=hellxz&password=xyz&scope=read_scope&grant_type=password
        //请求头放clientId和clientSecret
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        //客户端(凭证)模式只需要grantType
        // http://localhost:8080/oauth/token?grant_type=client_credentials
        //请求头放clientId和clientSecret
        String refreshToken = request.getParameter("refresh_token");

        //刷新token模式只需要grantType和refresh_token
        // http://ncsf6sb4:37bc1v7l8c@127.0.0.1:8547/oauth/token?grant_type=refresh_token&refresh_token=a9d43c25-c181-45a2-ba11-3f8cd5d6f3f4
        //请求头放clientId和clientSecret
        if (grantType.equals(AUTHORIZATION_CODE)) {
            if (StrUtil.isBlank(scope) || StrUtil.isBlank(redirectUri) || StrUtil.isBlank(code)) {
                log.info("{} mode : missing request token param", AUTHORIZATION_CODE);
                Result r = CommonCodeEnum.COMMON_MISSING_PARAM.toResult();
                ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
                return;
            }
        }

        if (grantType.equals(PASSWORD)) {
            if (StrUtil.isBlank(username) || StrUtil.isBlank(password)) {
                log.info("{} mode : missing request token param", PASSWORD);
                Result r = CommonCodeEnum.COMMON_MISSING_PARAM.toResult();
                ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
                return;
            }
        }

        if (grantType.equals(REFRESH_TOKEN)) {
            if (StrUtil.isBlank(refreshToken)) {
                log.info("{} mode : missing request token param", REFRESH_TOKEN);
                Result r = CommonCodeEnum.COMMON_MISSING_PARAM.toResult();
                ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
                return;
            }
        }

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null && authentication.isAuthenticated()) {
            filterChain.doFilter(request, response);
            return;
        }

        //不开启form,因为其不走该filter,采用basic

        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (header == null) {
            log.info("Basic Authentication Authorization header not found");
            Result r = Oauth2ExceptionCodeEnum.INVALID_REQUEST.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        header = header.trim();
        if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
            log.info("Basic Authentication Authorization header not found");
            Result r = Oauth2ExceptionCodeEnum.INVALID_REQUEST.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        if (header.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) {
            log.info("Empty basic authentication token");
            Result r = Oauth2ExceptionCodeEnum.UNAUTHORIZED_CLIENT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
        byte[] decoded;
        try {
            decoded = Base64.getDecoder().decode(base64Token);
        } catch (IllegalArgumentException e) {
            log.info("Failed to decode basic authentication token");
            Result r = Oauth2ExceptionCodeEnum.UNAUTHORIZED_CLIENT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        String token = new String(decoded, StandardCharsets.UTF_8);

        int delimit = token.indexOf(":");

        if (delimit == -1) {
            log.info("Invalid basic authentication token");
            Result r = Oauth2ExceptionCodeEnum.UNAUTHORIZED_CLIENT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        String clientId = token.substring(0, delimit);
        String clientSecret = token.substring(delimit + 1);

        if (StrUtil.isBlank(clientId) || StrUtil.isBlank(clientSecret)) {
            log.info("client_id or client_secret not found in form or basic");
            Result r = Oauth2ExceptionCodeEnum.INVALID_CLIENT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        ClientDetails clientDetails;
        try {
            clientDetails = this.clientDetailsService.loadClientByClientId(clientId);
        } catch (ClientRegistrationException e) {
            log.info("clientId [{}] not exist ", clientId);
            Result r = Oauth2ExceptionCodeEnum.INVALID_CLIENT.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        String bcryptSecret = clientDetails.getClientSecret();
        if (!passwordEncoder.matches(clientSecret, bcryptSecret)) {
            log.info("invalid clientSecret [}{]", clientSecret);
            Result r = Oauth2ExceptionCodeEnum.INVALID_CLIENT_SECRET.toResult();
            ResponseUtil.jsonResp(response).getWriter().write(JSONUtil.toJsonStr(r));
            return;
        }

        Collection<GrantedAuthority> authorities = clientDetails.getAuthorities();

        UsernamePasswordAuthenticationToken resultToken = new UsernamePasswordAuthenticationToken(clientId
                , bcryptSecret, authorities);

        SecurityContextHolder.getContext().setAuthentication(resultToken);

        filterChain.doFilter(request, response);
    }
}

例示参考:
https://blog.csdn.net/qq_31063463/article/details/83752459

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

推荐阅读更多精彩内容