09 Spring Security 之 redis保存token

SpringSecurity && JWT && Redis

上一章SpringBoot項(xiàng)目實(shí)戰(zhàn)(008)Spring Security(二)JWT中兵钮,實(shí)現(xiàn)了Spring Security的JWT認(rèn)證啤呼。但還是存在幾個(gè)問(wèn)題:

  1. 每次token訪問(wèn)晶渠,都要去數(shù)據(jù)庫(kù)訪問(wèn),效率低下鸯檬。
  2. token有效期參與了token的生成,無(wú)法延長(zhǎng),除非重新生成区匠。
  3. 登出時(shí)沒(méi)有清理token

所以本章打算:

  1. 使用redis作為緩存。
  2. 在登錄生成Token后帅腌,將token和username的對(duì)照關(guān)系保存在redis中驰弄,同時(shí)在redis中設(shè)置失效時(shí)間。
  3. 將部分高訪問(wèn)頻率的數(shù)據(jù)庫(kù)內(nèi)的用戶權(quán)限信息保存在redis中速客,提高效率戚篙。
  4. 最后在登出時(shí),清理redis中的token溺职。

redis集成

redis方面可參考的資料:

配置文件

pom文件

增加依賴(lài)即可:

<!-- redis starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- lettuce 池化 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.5.0</version>
</dependency>
<!-- jackson json 優(yōu)化緩存對(duì)象序列化 -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.6</version>
</dependency>

application-dev.yml

增加redis鏈接,除了host辅愿、port智亮,其他沿用即可。

spring:
    redis:
        # 數(shù)據(jù)庫(kù)索引点待,默認(rèn)0
        database: 0
        # redis實(shí)例IP 端口 密碼
        host: 172.17.0.2
        port: 6379
        password: 123456
        timeout: 3000
        lettuce:
            pool:
                max-active: 8
                max-wait: -1
                max-idle: 8
                min-idle: 0
            shutdown-timeout: 3000

LettuceRedisConfig

處理一些redis連接的問(wèn)題阔蛉,這里使用StringRedisSerializer,可以防止Redis中出現(xiàn)亂碼癞埠。

@Configuration
public class LettuceRedisConfig {
    @Bean
    public RedisTemplate<String, Object> oRedisTemplate(LettuceConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(stringRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(stringRedisSerializer);
        return redisTemplate;
    }
}

RedisUtil

新增一個(gè)RedisUtil状原,封裝RedisTemplate的一些操作。

package com.it_laowu.springbootstudy.springbootstudydemo.core.utils;
......
@Component
public final class RedisUtil {

    @Autowired
    private RedisTemplate<String, Object> oRedisTemplate;
    // ==========common=========
    /**
     * 指定緩存失效時(shí)間
     * @param key 鍵
     * @param time 時(shí)間(秒)
     * @return
     */
    public boolean expire(final String key, final long time) {
        try {
            if (time > 0) {
                oRedisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (final Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    // =========String==========
    /**
     * 普通緩存獲取
     * @param key 鍵
     * @return 值
     */
    public Object get(final String key) {
        return key == null ? null : oRedisTemplate.opsForValue().get(key);
    }

    /**
     * 普通緩存放入
     * @param key   鍵
     * @param value 值
     * @return true成功 false失敗
     */
    public boolean set(final String key, final Object value) {
        try {
            oRedisTemplate.opsForValue().set(key, value);
            return true;
        } catch (final Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 普通緩存放入并設(shè)置時(shí)間
     * @param key   鍵
     * @param value 值
     * @param time  時(shí)間(秒) time要大于0 如果time小于等于0 將設(shè)置無(wú)限期
     * @return true成功 false 失敗
     */
    public boolean set(final String key, final Object value, final long time) {
        try {
            if (time > 0) {
                oRedisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (final Exception e) {
            e.printStackTrace();
            return false;
        }
    }
......

controller

簡(jiǎn)單修改一下苗踪,以便校驗(yàn)Redis的使用有沒(méi)有問(wèn)題:

@RestController
@RequestMapping(value = "/admin")
public class AdminController {
    @RequestMapping(value="/keys/{key}",method=RequestMethod.GET)
    public String redisGet(@PathVariable(value = "key") String key) {
        Object val = redisUtil.get(key);
        return (String) val;
    }
    @RequestMapping(value="/keys/{key}",method=RequestMethod.POST)
    public Boolean redisSet(@PathVariable(value = "key") String key,String val) {
        return redisUtil.set(key, val);
    }
}

redis驗(yàn)證

在postman中颠区,設(shè)置一個(gè)string類(lèi)型的key:

redis setkey

在redis-cli中,client list查看鏈接的客戶端通铲,其中一個(gè)即redis-clidb=1毕莱,所以查不到對(duì)應(yīng)的key,使用select 0命令切換database,然后就可以查看到key了朋截。

redis check

使用postman蛹稍,讀取redis中的key:

redis getkey

token保存到redis

JwtProperties

原本的數(shù)據(jù)庫(kù)保存token可以取消,同時(shí)我們需要修改JwtProperties和yml文件部服,增加一些參數(shù)唆姐。

jwt:
    secret: "this is a secret"
    token-head: "Bearer "
    header-name: "Authorization"
    access-expiration: 3600
    roles-expiration: 300
    refresh-expiration: 604800
@Component
@ConfigurationProperties(prefix="jwt")
@Data
public class JwtProperties {
    private String secret="this is a secret";
    private String tokenHead = "Bearer ";
    private String headerName ="Authorization";
    private Integer accessExpiration = 60 * 60;
    private Integer rolesExpiration =60*5;
    private Integer refreshExpiration =60 * 60 * 24 * 7;
}

JwtTokenUtil

JwtTokenUtil關(guān)于token的種類(lèi)及生成方式需要大改一下,分為三種token:access廓八、roles奉芦、refresh。

  • access:訪問(wèn)token
  • roles:鑒權(quán)token
  • refresh:更新token的token

部分代碼:

    // 根據(jù)用戶信息生成token
    public Map<String, String> generateToken(UserDetails userDetails) {
        Map<String, String> rst = new HashMap<String, String>();
        // 訪問(wèn)token
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
        claims.put(CLAIM_KEY_CREATED, new Date());
        rst.put(getAccessTokenKey(), generateToken(claims, jwtProperties.getAccessExpiration()));

        rst.put(getRefreshTokenKey(), generateToken(claims, jwtProperties.getRefreshExpiration()));

        claims.put(CLAIM_KEY_ROLES, userDetails.getAuthorities());
        rst.put(getRoleTokenKey(), generateToken(claims, jwtProperties.getRolesExpiration()));

        return rst;
    }

    // 根據(jù)權(quán)限生成JWT的token
    private String generateToken(Map<String, Object> claims, Integer seconds) {
        return Jwts.builder().setClaims(claims).setExpiration(generateExpirationDate(seconds))
                .signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret()).compact();
    }
    /**
     * 生成token的過(guò)期時(shí)間
     */
    private Date generateExpirationDate(Integer seconds) {
        return new Date(System.currentTimeMillis() + (int) (seconds * 1000));
    }
    //根據(jù)token獲得roles
    public List<GrantedAuthority> getRolesFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        List<HashMap> roles =  (List<HashMap>) claims.get(CLAIM_KEY_ROLES);
        List<GrantedAuthority> authority = roles.stream().map(i->new SimpleGrantedAuthority((String) i.get("authority"))).collect(Collectors.toList());
        return authority;
    }
    //幾個(gè)key及生成方式
    public String getAccessTokenKey(){
        return "accesstoken";
    }
    public String getAccessTokenKey(String username){
        return username+":accesstoken";
    }
    public String getRefreshTokenKey(){
        return "refreshtoken";
    }
    public String getRefreshTokenKey(String username){
        return username+":refreshtoken";
    }
    public String getRoleTokenKey(){
        return "roletoken";
    }
    public String getRoleTokenKey(String username){
        return username+":roletoken";
    }

token存入redis

登錄時(shí)將三個(gè)token存入Redis剧蹂,返回兩個(gè)token給客戶端(roles沒(méi)必要返回)声功。

//MyAuthenticationProvider
package com.it_laowu.springbootstudy.springbootstudydemo.core.auth;
...
@Component
public class MyAuthenticationProvider implements AuthenticationProvider {
...
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
        logger.info(String.format("用戶%s登錄成功", username));
        // 生成新token
        Map<String,String> tokens = jwtTokenUtil.generateToken(user);
        String accesstoken = tokens.get(jwtTokenUtil.getAccessTokenKey());
        String refreshtoken = tokens.get(jwtTokenUtil.getRefreshTokenKey());
        String rolestoken = tokens.get(jwtTokenUtil.getRoleTokenKey());
        // 保存到 redis
        redisUtil.set(jwtTokenUtil.getAccessTokenKey(username),accesstoken);
        redisUtil.expire(jwtTokenUtil.getAccessTokenKey(username), jwtProperties.getAccessExpiration());
        redisUtil.set(jwtTokenUtil.getRefreshTokenKey(username),refreshtoken);
        redisUtil.expire(jwtTokenUtil.getRefreshTokenKey(username),jwtProperties.getRefreshExpiration());
        redisUtil.set(jwtTokenUtil.getRoleTokenKey(username), rolestoken);
        redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), jwtProperties.getRolesExpiration());
        // 綁定到當(dāng)前用戶
        user.setAccessToken(accesstoken);
        user.setRefreshToken(refreshtoken);
        return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
    }
...
}

同時(shí)調(diào)整一下MyAuthenticationSuccessHandler,將兩個(gè)token都返回国夜。

    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        //登錄成功返回
        String accessToken = ((MyUserDetails) authentication.getPrincipal()).getAccessToken();
        String refreshToken = ((MyUserDetails) authentication.getPrincipal()).getRefreshToken();
        ResultBody resultBody = new ResultBody("200", "登錄成功:\n"+accessToken+"\nrefreshtoken:\n"+refreshToken);
        //設(shè)置返回請(qǐng)求頭
        response.setContentType("application/json;charset=utf-8");
        //寫(xiě)出流
        PrintWriter out = response.getWriter();
        ObjectMapper mapper = new ObjectMapper();  
        out.write(mapper.writeValueAsString(resultBody));
        out.flush();
        out.close();
    }

使用accesstoken

只需要處理JwtokenAuthenticationFilter文件减噪,通過(guò)redis而不是數(shù)據(jù)庫(kù)驗(yàn)證token的有效性,以及獲得roles车吹。
如果roles存在筹裕,則利用,如果roles不存在窄驹,則從數(shù)據(jù)庫(kù)讀取朝卒,并將他緩存。

package com.it_laowu.springbootstudy.springbootstudydemo.core.auth;
...
@Component
public class JwtokenAuthenticationFilter extends OncePerRequestFilter {
...
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 取出auth
        String authHeader = request.getHeader(jwtProperties.getHeaderName());

        if (authHeader != null && authHeader.startsWith(jwtProperties.getTokenHead())) {
            // tokenBody
            String tokenBody = authHeader.substring(jwtProperties.getTokenHead().length());
            if (tokenBody != null) {
                String username = jwtTokenUtil.getUserNameFromToken(tokenBody);
                if (username != null) {
                    String accessToken = (String) redisUtil.get(jwtTokenUtil.getAccessTokenKey(username));
                    if (accessToken.equals(tokenBody)) {
                        String rolesToken = (String) redisUtil.get(jwtTokenUtil.getRoleTokenKey(username));
                        List<GrantedAuthority> authorities = null;
                        UserDetails userDetails = null;
                        if (rolesToken != null) {
                            // 緩存內(nèi)有權(quán)限
                            authorities = jwtTokenUtil.getRolesFromToken(rolesToken);
                            userDetails = new MyUserDetails(username, "", "", "", false, authorities);
                        } else {
                            // 提取數(shù)據(jù)乐埠,并存入緩存
                            userDetails = (MyUserDetails) myUserDetailsService.loadUserByUsername(username);
                            authorities = (List<GrantedAuthority>) userDetails.getAuthorities();
                            //生成三個(gè)token抗斤,只用一個(gè)
                            String newRoleToken = jwtTokenUtil.generateToken(userDetails).get(jwtTokenUtil.getRoleTokenKey());
                            redisUtil.set(jwtTokenUtil.getRoleTokenKey(username),newRoleToken);
                            redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), jwtProperties.getRolesExpiration());
                        }
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                                userDetails, null, authorities);
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
        }
        filterChain.doFilter(request, response);
    }
}

使用refreshtoken

為了刷新token,我在AdminController中暴露一個(gè)服務(wù)丈咐,根據(jù)RefreshToken獲得新的AccessToken瑞眼。

返回結(jié)構(gòu) ResultBody 調(diào)整

增加了Data屬性。

package com.it_laowu.springbootstudy.springbootstudydemo.core.base;
...
@Data
@Accessors(chain = true)
public class ResultBody<T> {
    private String code;
    private String message;
    private String detailMessage;
    private T data;

    public ResultBody() {
    }

    public ResultBody(String code, String message) {
        this.code = code;
        this.message = message;
    }
    public ResultBody(String code, String message, String detailMessage) {
        this.code = code;
        this.message = message;
        this.detailMessage = detailMessage;
    }
}

JwtTokenUtil 增加 refreshHeadToken

注意棵逊,這里對(duì)刷新頻率做了控制伤疙,你也可以把頻率參數(shù)放到JwtProperties中。

    public String refreshHeadToken(String refreshtoken,String accesstoken) {
        if (StrUtil.isEmpty(refreshtoken)) {
            return null;
        }
        String username = getUserNameFromToken(token);
        if (StrUtil.isEmpty(username)) {
            return null;
        }
        // 如果token在30分鐘之內(nèi)剛刷新過(guò)辆影,返回原token
        if (accesstoken != null && tokenRefreshJustBefore(accesstoken, 30 * 60)) {
            return "";
        } else {
            Map<String, Object> accessClaims = new HashMap<>();
            accessClaims.put(CLAIM_KEY_USERNAME,username);
            accessClaims.put(CLAIM_KEY_CREATED, new Date());
            return generateToken(accessClaims, jwtProperties.getAccessExpiration());
        }
    }

AdminController暴露服務(wù)

修改AdminController徒像,增加一個(gè)服務(wù):

    @RequestMapping(value = "/token/refresh/{token}", method = RequestMethod.GET)
    public ResultBody<String> refreshToken(@PathVariable(value = "token") String token) {
        ResultBody<String> rst = new ResultBody<String>().setCode("200");
        if (token == null) {
            return rst.setMessage("令牌不能為空");
        }
        String refreshtoken = token.substring(jwtProperties.getTokenHead().length());
        String username = jwtTokenUtil.getUserNameFromToken(refreshtoken);
        if (username == null) {
            return rst.setMessage("令牌格式有誤");
        }
        String accesstoken = (String) redisUtil.get(jwtTokenUtil.getAccessTokenKey(username));
        String new_accesstoken = jwtTokenUtil.refreshHeadToken(refreshtoken, accesstoken);
        if (new_accesstoken == null) {
            return rst.setMessage("令牌格式有誤");
        }
        if (new_accesstoken == "") {
            return rst.setMessage("令牌不要頻繁刷新");
        }
        redisUtil.set(jwtTokenUtil.getAccessTokenKey(username), new_accesstoken);
        return rst.setData(new_accesstoken);
    }

記得WebSecurityConfig開(kāi)放訪問(wèn)。

    .antMatchers("/admin/token/**").permitAll()

postman驗(yàn)證

  • 使用用戶密碼登錄成功蛙讥。
登錄成功
  • 查看redis中的key锯蛀。
redis中的key
  • 使用token訪問(wèn)成功。
token訪問(wèn)成功
  • 清除accesstoken次慢。
清除token
  • 刷新token旁涤,用新token訪問(wèn)成功翔曲。
刷新token

登出處理

由于客戶端長(zhǎng)期擁有的僅僅是refreshtoken,所以前端可以根據(jù)username劈愚,也可以使用refreshtoken登出系統(tǒng)(即清除redis中信息)部默。

比如我們?cè)赼dmincontroller中加個(gè)清除token服務(wù)即可:

    @RequestMapping(value = "/token/{token}", method = RequestMethod.DELETE)
    public ResultBody<String> deleteToken(@PathVariable(value = "token") String token) {
        ResultBody<String> rst = new ResultBody<String>().setCode("200");
        if (token == null) {
            return rst.setMessage("令牌不能為空");
        }
        String refreshtoken = token.substring(jwtProperties.getTokenHead().length());
        String username = jwtTokenUtil.getUserNameFromToken(refreshtoken);
        if (username == null) {
            return rst.setMessage("令牌格式有誤");
        }
        redisUtil.expire(jwtTokenUtil.getAccessTokenKey(username), 1);
        redisUtil.expire(jwtTokenUtil.getRefreshTokenKey(username), 1);
        redisUtil.expire(jwtTokenUtil.getRoleTokenKey(username), 1);
        return rst;
    }
刪除token,登出
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末造虎,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子纷闺,更是在濱河造成了極大的恐慌算凿,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,451評(píng)論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件犁功,死亡現(xiàn)場(chǎng)離奇詭異氓轰,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)浸卦,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,172評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門(mén)筒扒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)您市,“玉大人,你說(shuō)我怎么就攤上這事∥涿ィ” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 164,782評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵岭埠,是天一觀的道長(zhǎng)脆丁。 經(jīng)常有香客問(wèn)我,道長(zhǎng)稚叹,這世上最難降的妖魔是什么焰薄? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,709評(píng)論 1 294
  • 正文 為了忘掉前任,我火速辦了婚禮扒袖,結(jié)果婚禮上塞茅,老公的妹妹穿的比我還像新娘。我一直安慰自己季率,他們只是感情好野瘦,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,733評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著蚀同,像睡著了一般缅刽。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上蠢络,一...
    開(kāi)封第一講書(shū)人閱讀 51,578評(píng)論 1 305
  • 那天衰猛,我揣著相機(jī)與錄音,去河邊找鬼刹孔。 笑死啡省,一個(gè)胖子當(dāng)著我的面吹牛娜睛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播卦睹,決...
    沈念sama閱讀 40,320評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼畦戒,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了结序?” 一聲冷哼從身側(cè)響起障斋,我...
    開(kāi)封第一講書(shū)人閱讀 39,241評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎徐鹤,沒(méi)想到半個(gè)月后垃环,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,686評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡返敬,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,878評(píng)論 3 336
  • 正文 我和宋清朗相戀三年遂庄,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片劲赠。...
    茶點(diǎn)故事閱讀 39,992評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡涛目,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出凛澎,到底是詐尸還是另有隱情霹肝,我是刑警寧澤,帶...
    沈念sama閱讀 35,715評(píng)論 5 346
  • 正文 年R本政府宣布塑煎,位于F島的核電站阿迈,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏轧叽。R本人自食惡果不足惜苗沧,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,336評(píng)論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望炭晒。 院中可真熱鬧待逞,春花似錦、人聲如沸网严。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,912評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)震束。三九已至怜庸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間垢村,已是汗流浹背割疾。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,040評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留嘉栓,地道東北人宏榕。 一個(gè)月前我還...
    沈念sama閱讀 48,173評(píng)論 3 370
  • 正文 我出身青樓拓诸,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親麻昼。 傳聞我的和親對(duì)象是個(gè)殘疾皇子奠支,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,947評(píng)論 2 355

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