Spring Security + jwt 實現(xiàn)安全控制

權(quán)限系統(tǒng)是每個系統(tǒng)必不可少的一部分撑蚌,我們可以自己實現(xiàn)根據(jù)自己的需求采用不同的技術(shù)方案雕拼。最近在我們的管理后臺尚使用了Spring Security + JWT實現(xiàn)了后臺的權(quán)限系統(tǒng),包括用戶登錄,角色分配腕扶,鑒權(quán)與授權(quán)。

理解權(quán)限框架本質(zhì)

有哪些技術(shù)方案惩淳?
業(yè)內(nèi)通用的做法有Shiro蕉毯,Spring Security乓搬,還有很多公司自己實現(xiàn)的基于url攔截的權(quán)限框架。從個人使用體驗上來說代虾,有好用的輪子就應(yīng)該選擇用經(jīng)過很多人驗證過的輪子进肯。而不是自己沉迷于簡單的增刪改,時間應(yīng)該花在研究security的原理棉磨,代碼組織架構(gòu)上江掩,因為我也見過幾個項目自己手寫的權(quán)限框架,并沒有用的很流暢乘瓤,反而總是在一些url匹配不夠通用上問題頻出环形。
那么權(quán)限框架的本質(zhì)是什么?
對衙傀,就是匹配邏輯抬吟。舉個簡單例子,網(wǎng)站用戶A擁有權(quán)限標識:"user_add","coupon_delete","coupon_all",接收到request請求后统抬,判斷此請求需要的權(quán)限標識是否匹配火本。權(quán)限標識可以是:menu_url,menu_code,role_code等等,我們可以選擇系統(tǒng)中變動頻率小的變量來做角色標識聪建。因為這個權(quán)限標識只能硬編碼或者ant風(fēng)格匹配在目標資源上钙畔。舉個例子:假如你的系統(tǒng)角色固定,那就用角色code作權(quán)限標識金麸,若是菜單基本固定擎析,就用菜單url做標識。后面會具體講到

用戶登錄的邏輯和jwt

用戶到底是怎么登錄的挥下?
這個問題對于初級工程師來說會很迷惑揍魂,曾經(jīng)也經(jīng)歷過。所以簡單說明下见秽。在一般的web軟件開發(fā)中愉烙,開發(fā)者不需要關(guān)注會話這件事情,因為tomcat容器自動幫我們管理的會話session解取,他的流程是這樣的步责,用戶訪問服務(wù),服務(wù)端生成session會話禀苦,并且把sessionId回寫到瀏覽期的cookie中蔓肯,瀏覽器后面的每次請求就會攜帶上這個sessionId。服務(wù)端就能標識這個用戶了振乏,至于登陸鑒權(quán)的邏輯都是基于你能唯一標識當前的用戶來做的蔗包。通用的做法是,用戶成功登陸后慧邮,服務(wù)端會把用戶信息存放在sessionId標識的session中调限。隨著用戶體量增多舟陆,在分布式的環(huán)境下一般的做法是session共享,或者采用redis接替tomcat管理session會話的方案耻矮。
為什么要用jwt秦躯?
全程是json web token,關(guān)于jwt是什么裆装,可以參考阮一峰的文章:JSON Web Token 入門教程踱承。使用了jwt后,我們完全把登陸信息存放在客戶端哨免,每次認證都是由客戶端帶著鑒權(quán)參數(shù)過來茎活。具體的邏輯是服務(wù)端生成token,包含token有效期琢唾,存放的鑒權(quán)信息等载荔,下發(fā)給客戶端〔商遥客戶端自放在本地身辨。服務(wù)端就可以提供無狀態(tài)的服務(wù)了,非常方便擴展芍碧。

實際案例

導(dǎo)入依賴

<!-- 基于spring boot  -->
 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.2.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

配置security

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 讀取忽略的配置文件
     */
    @Autowired
    private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;

    /**
     * 未攜帶token的異常處理
     */
    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    /**
     * 業(yè)務(wù)的用戶密碼驗證
     */
    @Autowired
    private JwtUserDetailsService jwtUserDetailsService;

    /**
     * 自定義基于JWT的安全過濾器
     */
    @Autowired
    private JwtAuthorizationTokenFilter authenticationTokenFilter;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 配置基于數(shù)據(jù)庫的用戶密碼查詢  密碼使用security自帶的BCryptEncoder(結(jié)合了隨機鹽和加密算法)
        auth.userDetailsService(jwtUserDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();

        // 【1】授權(quán)異常及不創(chuàng)建會話(不使用session)
        http.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        //允許不登錄訪問的接口
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();

        // 【2】 從配置文件讀取url
        registry.antMatchers(HttpMethod.OPTIONS, "/**").anonymous();
        filterIgnorePropertiesConfig.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

        //需要登錄才允許訪問
        filterIgnorePropertiesConfig.getAuthenticates().forEach(url -> registry.antMatchers(url).authenticated());

        //其它的嚴格控制權(quán)限,必須權(quán)限擁有的菜單中對應(yīng)的api_url才允許訪問 【3】 權(quán)限控制
        //registry.anyRequest().access("@permissionService.hasPermission(request,authentication)");
        registry.anyRequest().authenticated();

        // 把token攔截器配置在security 用戶名和密碼攔截器之前  【4】 從token解析的邏輯
        http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // AuthenticationTokenFilter will ignore the below paths
        web.ignoring()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                );
    }
}
處理配置文件
@Data
@Configuration
@RefreshScope
@ConditionalOnExpression("!'${ignore}'.isEmpty()") 
@ConfigurationProperties(prefix = "ignore")
public class FilterIgnorePropertiesConfig {

    private List<String> urls = new ArrayList<>();

    private List<String> authenticates = new ArrayList<>();

}

application.yml

ignore:
  urls:
  - /auth/**
  - /act/**
  - /druid/*
  - /*/user/login

anonymous:都支持訪問
permitAll():不登陸也能訪問
authenticated():登陸就能訪問
access():嚴格控制權(quán)限

token攔截器

攔截器主要做了這么幾件事:

1.從請求頭里面獲取token
2.解析token里面存放的用戶信息
3.用戶信息不為空号俐,且當前請求SecurityContextHolder(默認的實現(xiàn)是ThreadLocal)中的用戶信息為空泌豆,就設(shè)置進去。
3.1用redis標記了token是否是用戶手動過期掉的吏饿,因為token本身存放了過期時間 無法修改踪危。
3.2根據(jù)3中簡要的用戶信息查詢?nèi)坑脩粜畔ⅲń巧砺洌藛蔚日暝丁H绻阕銐蛐湃蝨oken,也可以省略這里查詢數(shù)據(jù)庫笨忌。

@Slf4j
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;

    private OrRequestMatcher orRequestMatcher;

    @Autowired
    private UserDetailsService userDetailsService;

    private final JwtTokenUtil jwtTokenUtil;

    private final String tokenHeader;

    private int expiration;

    @Autowired
    private RedisManager redisManager;

    @PostConstruct
    public void init() {
// 初始化忽略的url不走過此濾器
        List<RequestMatcher> matchers = filterIgnorePropertiesConfig.getUrls().stream()
                .map(url -> new AntPathRequestMatcher(url))
                .collect(Collectors.toList());
        orRequestMatcher = new OrRequestMatcher(matchers);
    }

    public JwtAuthorizationTokenFilter(JwtTokenUtil jwtTokenUtil, @Value("${jwt.header}") String tokenHeader, @Value("${jwt.expiration}") Long expire) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.tokenHeader = tokenHeader;
        this.expiration = (int) (expire / 1000);
    }

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

        String requestURI = request.getRequestURI();
        log.debug("processing authentication for '{}'", requestURI);
        final String requestHeader = request.getHeader(this.tokenHeader);

        JwtUser jwtUser = null;
        String authToken = null;
        if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
            authToken = requestHeader.substring(7);
            try {
                jwtUser = jwtTokenUtil.getJwtUserFromToken(authToken);
            } catch (ExpiredJwtException e) {
                // token 過期
                throw new AccountExpiredException("登陸狀態(tài)已過期");
            } catch (MalformedJwtException e) {
                log.info("解析前端傳過來的Authentication錯誤蓝仲,但不影響業(yè)務(wù)邏輯!token:{}", requestHeader);
            } catch (Exception e) {
                log.info("JwtAuthorizationTokenFilter處理異常官疲!{}", e.getMessage());
            }
        }
        log.debug("checking authentication for user '{}'", jwtUser);

        //生成jwt的token的過期時間是一天袱结,而這里控制實際過期時間是兩個小時(application.yml配置的過期時間)
        if (jwtUser != null && jwtUser.getUsername() != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            if (redisManager.exists(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken)) {
                redisManager.expire(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken, expiration);
            } else {
                throw new AccountExpiredException("登錄信息已經(jīng)過期或已經(jīng)退出登錄,請重新登錄途凫!");
            }

            UserDetails user = userDetailsService.loadUserByUsername(jwtUser.getUsername());
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            log.debug("authorizated user '{}', setting security context", user.getUsername());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    /**
     * 可以重寫
     * @param request
     * @return 返回為true時垢夹,則不過濾即不會執(zhí)行doFilterInternal
     * @throws ServletException
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return orRequestMatcher.matches(request);
    }
}
從持久層查詢用戶

1.把用戶的權(quán)限標識封裝到GrantedAuthority對象,這是security封裝的權(quán)限頂級接口维费。
2.檢驗菜單權(quán)限的時候就會通過這里封裝的權(quán)限標識來比對果元。
3.關(guān)于權(quán)限標識的選取上文有提到促王,盡量選擇不容易變動的變量(角色Code|菜單Code|菜單path)。
4.這個對象就是放在線程變量的用戶對象,serurity的注解也會從這里取出權(quán)限標識來比對

@Primary
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class JwtUserDetailsService implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username){

        // 根據(jù)登陸的用戶名查詢用戶相關(guān)的信息
        UserEntity user = sysUserService.loadUserByUsername(username);

        if (user == null) {
            throw new UsernameNotFoundException("該賬戶不存在而晒,請聯(lián)系管理員添加");
        } else {
            return create(user);
        }
    }

    public UserDetails create(UserEntity user) {
        JwtUser jwtUser = new JwtUser();
        BeanUtils.copyProperties(user, jwtUser);

        Set<String> roleCodeList = new HashSet<>();
//        roleCodeList.addAll(user.getRoleIdList().stream().map(String::valueOf).collect(Collectors.toList()));
// 選取菜單permission作為權(quán)限標識
        roleCodeList.addAll(user.getPermissionList().stream().filter(StringUtils::isNotEmpty).collect(Collectors.toSet()));
        Collection<? extends GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(roleCodeList.toArray(new String[0]));
        jwtUser.setAuthorities(authorities);

        return jwtUser;
    }

}
用戶登陸的流程

上面的部分是用戶帶著token來訪問授權(quán)接口蝇狼,或者不帶token訪問公用接口。那么token是怎么生成的呢欣硼?我們需要暴露公開的登陸接口题翰,校驗用戶信息狀態(tài)等。成功通過校驗后诈胜,把部分用戶信息封裝在token里面下發(fā)給客戶端豹障。
這是一個基于的jjwt的jwtToken工具類:

@Component
@Slf4j
public class JwtTokenUtil {

    private transient Clock clock = DefaultClock.INSTANCE;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    @Value("${jwt.header}")
    private String tokenHeader;

    @Autowired
    private RedisManager redisManager;

    private ObjectMapper mapper = new ObjectMapper();

    public JwtUser getJwtUserFromToken(String token) throws Exception {
        String subject = getClaimFromToken(token, Claims::getSubject);
        Map<String, Object> subjectMap = mapper.readValue(subject, Map.class);

        // 在token中存儲了用戶ID 用戶名  用戶狀態(tài)
        JwtUser jwtUser = new JwtUser();
        jwtUser.setUserId(Long.valueOf(subjectMap.get("userId").toString()));
        jwtUser.setUsername((String) subjectMap.get("username"));
        jwtUser.setState((Integer) subjectMap.get("state"));

        return jwtUser;
    }

    public Date getIssuedAtDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getIssuedAt);
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    private Boolean isTokenExpired(String token) {
        final Date expirationDate = getExpirationDateFromToken(token);
        return expirationDate.before(clock.now());
    }

    private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
        return (lastPasswordReset != null && created.before(lastPasswordReset));
    }

    private Boolean ignoreTokenExpiration(String token) {
        // here you specify tokens, for that the expiration is ignored
        return false;
    }

    // 登陸校驗成功后調(diào)用這個接口生成token下發(fā)
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();

        try {
            String subject = mapper.writeValueAsString(userDetails);
            log.info("generateToken subject:{}", subject);
            String token = doGenerateToken(claims, subject);
            redisManager.set(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + token, "1", (int) (expiration / 1000));
            return token;
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("Cannot format json", e);
        }
    }

    private String doGenerateToken(Map<String, Object> claims, String subject) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean canTokenBeRefreshed(String token, Date lastPasswordReset) {
        final Date created = getIssuedAtDateFromToken(token);
        return !isCreatedBeforeLastPasswordReset(created, lastPasswordReset)
                && (!isTokenExpired(token) || ignoreTokenExpiration(token));
    }

    public String refreshToken(String token) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        final Claims claims = getAllClaimsFromToken(token);
        claims.setIssuedAt(createdDate);
        claims.setExpiration(expirationDate);

        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) throws Exception {
        JwtUser user = (JwtUser) userDetails;
        final JwtUser jwtUser = getJwtUserFromToken(token);
        return (
                jwtUser.getUsername().equals(user.getUsername())
                        && !isTokenExpired(token));
    }

    private Date calculateExpirationDate(Date createdDate) {
        //過期時間1天
        return new Date(createdDate.getTime() + 1000 * 60 * 60 * 24);
    }
}
jwt token刷新機制

我們回顧下token機制相比傳統(tǒng)的session機制帶來的好處,服務(wù)無狀態(tài)焦匈,服務(wù)端不用存儲用戶的session血公,用戶數(shù)過多也不會占用資源,方便服務(wù)水平拓展...缓熟,token也有一個缺點就是由于token的有效期是保存在客戶端的累魔,當用戶主動退出,或者服務(wù)端要踢出用戶的時候很難做到够滑。refresh token可以實現(xiàn)這種場景垦写,并且能實現(xiàn)用戶無感知登陸。訪問資源的稱之為access token彰触,客戶端訪問所有的資源都需要帶上梯投,它的有效期比較短。refresh token是用來刷新access token况毅,它的有效期是比較長的分蓖。接下來回顧一下整個會話管理流程:

  • 客戶端使用用戶名和密碼認證
  • 服務(wù)端校驗用戶名和密碼,下發(fā)access_token(2小時有效)和refresh_token(7天有效)
  • 客戶端帶著access_token訪問需要認證的資源尔许,access_token有效么鹤,返回資源。
  • access_token過期味廊,返回和客戶端約定的響應(yīng)碼蒸甜,客戶端帶著refresh_token刷新access_token.
  • refresh_token 有效,正常返回余佛,refresh_token過期走重新登陸流程迅皇。
  • 客戶端使用新的 access_token 訪問需要認證的接口


    會話管理流程

將生成的refresh_token以及過期時間存儲在服務(wù)端的數(shù)據(jù)庫中,只有在申請新的access_token時才會驗證衙熔。同時我們也能實現(xiàn)在服務(wù)端踢出用戶登颓,只需要禁用|刪除refresh_token,用戶在刷新access_token時就會重新去登陸红氯。(時間精度的控制取決于access_token的有效期)

接口權(quán)限控制

當我們完成了用戶登陸-token下發(fā)-請求攔截認證的流程后框咙,當request到達Controller層咕痛,SecurityContextHolder已經(jīng)存儲了用戶的常用信息(用戶名,權(quán)限標識等等)喇嘱,所以在Controller層可以直接使用注解來鑒權(quán)坑资。

@PreAuthorize("hasAuthority('test_menu_code')")
    @PostMapping("/getUserInfo")
    public ResponseResult getUserInfo() {
        return new ResponseResult(getUser());
    }

至此眯牧,完成了整個權(quán)限控制。代碼只是列出了關(guān)鍵的部分,沒有達到運行的流程闻坚,需要有一定基礎(chǔ)的程序員來根據(jù)自己的業(yè)務(wù)定制挎狸。只是提供了一個企業(yè)級權(quán)限控制的實現(xiàn)方案振湾。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末暇务,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子拿撩,更是在濱河造成了極大的恐慌衣厘,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,639評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件压恒,死亡現(xiàn)場離奇詭異影暴,居然都是意外死亡,警方通過查閱死者的電腦和手機探赫,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,277評論 3 385
  • 文/潘曉璐 我一進店門型宙,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人伦吠,你說我怎么就攤上這事早歇。” “怎么了讨勤?”我有些...
    開封第一講書人閱讀 157,221評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長晨另。 經(jīng)常有香客問我潭千,道長,這世上最難降的妖魔是什么借尿? 我笑而不...
    開封第一講書人閱讀 56,474評論 1 283
  • 正文 為了忘掉前任刨晴,我火速辦了婚禮,結(jié)果婚禮上路翻,老公的妹妹穿的比我還像新娘狈癞。我一直安慰自己,他們只是感情好茂契,可當我...
    茶點故事閱讀 65,570評論 6 386
  • 文/花漫 我一把揭開白布蝶桶。 她就那樣靜靜地躺著,像睡著了一般掉冶。 火紅的嫁衣襯著肌膚如雪真竖。 梳的紋絲不亂的頭發(fā)上脐雪,一...
    開封第一講書人閱讀 49,816評論 1 290
  • 那天,我揣著相機與錄音恢共,去河邊找鬼战秋。 笑死,一個胖子當著我的面吹牛讨韭,可吹牛的內(nèi)容都是我干的脂信。 我是一名探鬼主播,決...
    沈念sama閱讀 38,957評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼透硝,長吁一口氣:“原來是場噩夢啊……” “哼狰闪!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起蹬铺,我...
    開封第一講書人閱讀 37,718評論 0 266
  • 序言:老撾萬榮一對情侶失蹤尝哆,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后甜攀,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體秋泄,經(jīng)...
    沈念sama閱讀 44,176評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,511評論 2 327
  • 正文 我和宋清朗相戀三年规阀,在試婚紗的時候發(fā)現(xiàn)自己被綠了恒序。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,646評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡谁撼,死狀恐怖歧胁,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情厉碟,我是刑警寧澤喊巍,帶...
    沈念sama閱讀 34,322評論 4 330
  • 正文 年R本政府宣布,位于F島的核電站箍鼓,受9級特大地震影響崭参,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜款咖,卻給世界環(huán)境...
    茶點故事閱讀 39,934評論 3 313
  • 文/蒙蒙 一何暮、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧铐殃,春花似錦海洼、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,755評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春词疼,著一層夾襖步出監(jiān)牢的瞬間俯树,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,987評論 1 266
  • 我被黑心中介騙來泰國打工贰盗, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留许饿,地道東北人。 一個月前我還...
    沈念sama閱讀 46,358評論 2 360
  • 正文 我出身青樓舵盈,卻偏偏與公主長得像陋率,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子秽晚,可洞房花燭夜當晚...
    茶點故事閱讀 43,514評論 2 348

推薦閱讀更多精彩內(nèi)容