使用Shiro和token进行无状态登录

  created  by  鱼鱼 {{tag}}
创建于 2019年01月08日 18:33:17 最后修改于 2020年03月22日 11:35:41

        我们之前可以使用shiro实现登录,但这些都是基于session或是cookie实现的,这些只能用于单机部署的服务,或是分布式服务共享会话,显然后者开销极大,所以JWT(JSON Web Token)应运而生,JWT是一套约定好的认证协议,通过请求携带令牌来访问那些需鉴权的接口。

        我们在这里使用token,原理类似,但是规则更为简单,没有形式上的约束,只是在请求Head或是body中添加token用于校验用户身份,token是可以和会话共存的,此处我们使用Shiro的会话登录结合JWT来实现无状态登录,从而实现扫码登录和一般的接口访问授权。

        项目中,需要实现无状态登录(单点登录,SSO),但是同时也要保持Shiro本身自带的会话登录。

        Shiro本身并不适合做基于鉴权的无状态登录,更适合它的应用场景是单体应用,这点需要注意。

有关token

     此文中有关token和SSO的叙述比较粗略,只是大意阐释了流程和部分相关概念。

 token一般的校验流程

 长token与短token

        此处采用OAuth2.0的标准,多数服务都是客户端(浏览器)与服务端后台分离的令牌,前端用于确认用户身份的是短token(通常称access token),而实际保存用户信息(包含用户基本信息、角色、权限等)的是长token(通常称refresh token),只存在后台,以短token映射长token实现用户身份和权限校验。

        这里的长与短,不仅指token所存信息量的多少或是token文本的长度,还指token的有效时间,一般短token对应着会话,存活时间较短,临近过期若用户仍未登出会进行刷新。使用长短token的模式其实近似于 sessionid/uuid+内存或是nosql存储用户实体对象 的处理方案。

 JWT、token

        token相比之下是一个很广泛的定义,一切表示令牌的内容都可以称作是token,jwt算是token中相对很经典、适用范围很广的一套规范,它将用户访问令牌封装在Json格式的数据中,包含在每次请求中。java中相关的实现为auth0的包。

无状态登录与单点登录

        前文中所述的无状态登陆与单点登录还是有所不同的,常见安全框架(例如Shiro)的默认行为是有状态登录,他在cookie中存储了sessionId,同时后台中保存了sessionId对应的用户映射,这样对于每一个sessionId后台都记录了对应的登录信息,有状态的登录缺憾是在集群环境下需要进行session共享,而且功能的实际实现一般都是集成了安全框架,对外不可见,也不便于扩展(扩展可以尝试使用Spring Security安全框架,但是相较Shiro上手门槛较高)。

        而无状态登录就是本文中所说的token验证方式,相比于有状态登录信息默认存储在服务本地内存中,无状态的用户信息(token与信息映射)存储在服务公有资源中,服务的横向扩展是毫无压力的,同时也解决了服务多节点跳动的sessionId变化问题,但是需要考虑token的安全存储。

        总而言之,有状态服务的特点和优缺点为:

  • 用户信息映射实时生成,安全可靠,不必担心登录信息被复用

  • 登录用户信息存储在服务内存中,多节点的服务必须做会话共享

  • 安全框架集成方便,但是不便于功能的扩展

    相较之下,无状态服务的特点为:

  • token与用户信息映射是固定的编码,要通过短token、非对称加密等方式避免用户直接获取到token的内容

  • token编码方式固定,可不存储登录用户token或是在公有空间(etc:redis)存储短token,不必考虑服务切换会话登录状态失效的问题

  • 需要自己集成,但是定制程度高,业务可见性高,便于扩展

为什么需要SSO

        前文中所说的此网站扫码登录需要用到SSO,凭此将用户的登录状态由手机客户端转移至浏览器中,但是这种情况下的登录状态转移是SSO的落地实现,并不能很好的解释为什么要通过SSO实现它。例如,我们也可以将用户名和密码加密后直接传入扫码网站实现登录,但这么做显然是不合时宜的,主要因为:

  1. 安全性没有保障,不使用单点登录就要求用户端留存相关密码密钥,这样一来很容易直接泄露不更改便永久有效的密码,而不是一段会过期的token;

  2. 有些使用登录中心的应用跟客户端(浏览器)并不互相信任,例如对接第三方平台(QQ、微信等)实现登陆,第三方肯定不能将用户的全部信息传回,他们反而把整个登陆过程承包了下来,返回给我们的只是用户能对外公开的基本信息,或是一段id,每次都要请求第三方再校验;

业务处理

 假定的一般实现:不使用框架

        假如实现登录功能不使用框架(Shiro),而只在后台使用生成jwt的相关依赖,我们需要一片能被所有服务访问的公共存储维护用户的在线信息,最佳手段是采取诸如nosql的k-v存储,此处假定采用redis实现,那么需要在redis中存储如下数据:

  • 用户标识(id)和token(此处指长token)的映射

  • token和用户的信息映射

        但是因为需要将用户的token存在前端,参考上文中的长短token,我们还需要存储用户到短token的映射,可将上面的用户标识直接更正为短token(或者可以使用自定义的sessionId,注意务必确保全局唯一)。

 无状态登录系统的标配

无状态登录系统标配的组件和逻辑有以下几种:

  1. 一套token和用户的映射关系;

  2. 一套针对用户密码的双向加密或是单向编码算法;

  3. 一个用户鉴权中心,可以是独立的服务;

  4. 一个用户信息存储介质(一般是noSQL);

可以基于Shiro开发鉴权中心,但是实际上不是很合适。

 流程设计

    基于以上的思想,设计使用Shiro的单点登录,整体方案的实施需要两个主要的模块,身份认证和安全控制,本篇身份认证采用jwt,安全控制采用Shiro框架。

流程设计如下:

实战

    关于基本使用请参考安全框架的使用:shiro——鱼鱼的Java小站,此处主要附Token的生成和基于Redis的存储。

    编写加密算法

@Service
public class PswEncodeService {
    //使用了SHA-1
    public String pswEncode(String text,String salt){
        return encode(text+salt,"SHA1",encodeCount(salt));
    }

    public boolean checkPsw(String text,String cipher,String salt){
        return checkcode(text+salt,cipher,"SHA1",encodeCount(salt));
    }

    /**
     * @param text 待加密明文
     * @param key 加密方式
     * @param x 递归加密次数
     *  采用地柜加密更加安全
     */
    public String encode(String text,String key,int x){
        if (text == null) {
            return null;
        }
            if(x>1){
                try {
                    MessageDigest messageDigest = MessageDigest.getInstance(key);
                    messageDigest.update(text.getBytes());
                    return encode(getFormattedText(messageDigest.digest()),key,x-1);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }else{
                return text;
            }
    }

    public String encode(String text,String key){
        return encode(text,key,1);
    }

    public boolean checkcode(String text,String cipher,String key,int x){
        if(encode(text,key,x).equals(cipher))
            return true;
        else
            return false;
    }

    /**
     * 按照既定的明文编码规则获取加密迭代次数 为1000——1999次不等
     * @param text key明文
     *
     */
    public int encodeCount(String text){
        int a=1024
        //可以设计不同的迭代次数
        return a;
    };

    private static final char[] HEX = {'0', '1', '2', '3', '4', '5',
            '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};


    private static String getFormattedText(byte[] bytes) {
        int len = bytes.length;
        StringBuilder buf = new StringBuilder(len * 2);
        // 把密文转换成十六进制的字符串形式
        for (int j = 0; j < len; j++) {
            buf.append(HEX[(bytes[j] >> 4) & 0x0f]);
            buf.append(HEX[bytes[j] & 0x0f]);
        }
        return buf.toString();
    }



}

Token生成与校验、存储

token的生成方法与校验,首先引入依赖:

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.1</version>
</dependency>

然后编写token类:

@Service
public class TokenService {
    // 过期时间60分钟
    private static final long EXPIRE_TIME = 60*60*1000;
    @Autowired
    private PswService pswService;
    @Autowired
    LoginLogMapper loginLogMapper;
    private Logger logger = LoggerFactory.getLogger(TokenService.class);

    /**
     * 校验token是否正确
     * @param token 密钥
     * @param secret 用户的密码
     * @return 是否正确
     */
    public boolean verify(String token, String username, String secret) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(pswEncodeFacade.pswEncode(secret,username));
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    /**
     * 获得token中的信息无需secret解密也能获得
     * @return token中包含的用户名
     */
    public String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);

            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    public String getUserId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("id").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成签名,60min后过期
     * @param username 用户名
     * @param secret 用户的密码
     * @return 超级加密的token
     */
    public String sign(String username, String secret,String id) {
        Date date = new Date(System.currentTimeMillis()+EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(pswEncodeFacade.pswEncode(secret,username));
        // 附带username信息,也可在token中直接标示更多信息
        return JWT.create()
                .withClaim("username", username)
                .withClaim("id",id)
                .withExpiresAt(date)
                .sign(algorithm);
    }


}

基于Redis存储Shiro会话

编写cacheManager:

@Component
public class RedisCacheManager4Shiro implements CacheManager {

    @Resource
    RedisTemplate<String, Object> redisTemplate;
    private static final Logger logger = LoggerFactory.getLogger(RedisCacheManager4Shiro.class);
    //@Resource RedisUtil redisUtil;

    @Override
    public <K, V> Cache<K, V> getCache(String arg0) throws CacheException {
        return new RedisCache<K, V>();
    }

    class RedisCache<K, V> implements Cache<K, V>{

        public RedisCache() {
            redisTemplate.boundHashOps(CACHE_KEY).expire(180, TimeUnit.MINUTES);
        }
        //Cache 前缀
        private static final String CACHE_KEY = "shiro_redis_subject";

        @Override
        public void clear() throws CacheException {
            redisTemplate.delete(CACHE_KEY);
        }

        private String toString(Object obj){
            if(obj instanceof String){
                return obj.toString();
            }else{
                return JSONObject.toJSONString(obj);
            }
        }

        @SuppressWarnings("unchecked")
        @Override
        public V get(K k) throws CacheException {
            logger.info("get field:{}", toString(k));
            return (V)redisTemplate.boundHashOps(CACHE_KEY).get(k);
        }

        @SuppressWarnings("unchecked")
        @Override
        public Set<K> keys() {
            logger.info("keys");
            return (Set<K>)redisTemplate.boundHashOps(CACHE_KEY).keys();
        }

        @Override
        public V put(K k, V v) throws CacheException {
            logger.info("put field:{}, value:{}", toString(k), toString(v));
            redisTemplate.boundHashOps(CACHE_KEY).put(k, v);
            return v;
        }

        @Override
        public V remove(K k) throws CacheException {
            logger.info("remove field:{}", toString(k));
            V v = get(k);
            redisTemplate.boundHashOps(CACHE_KEY).delete(k);
            return v;
        }

        @Override
        public int size() {
            int size = redisTemplate.boundHashOps(CACHE_KEY).size().intValue();
            logger.info("size:{}", size);
            return size;
        }

        @SuppressWarnings("unchecked")
        @Override
        public Collection<V> values() {
            logger.info("values");
            return (Collection<V>)redisTemplate.boundHashOps(CACHE_KEY).values();
        }

        public String getCacheKey() {
            return "RedisCache";
        }

    }

}

然后在我们配置时指定:

//配置核心安全事务管理器
@Bean(name = "securityManager")
public SecurityManager securityManager(//@Qualifier("sessionManager")WebSessionManager webSessionManager,
                                       @Qualifier("authRealm") AuthRealm authRealm,
                                       @Qualifier("redisCacheManager4Shiro") RedisCacheManager4Shiro redisCacheManager4Shiro) {
    DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
    manager.setCacheManager(redisCacheManager4Shiro);
    manager.setSessionManager(redisSessionManager);
    manager.setRealm(authRealm);
    return manager;
}


评论区
评论
{{comment.creator}}
{{comment.createTime}} {{comment.index}}楼
评论

使用Shiro和token进行无状态登录

使用Shiro和token进行无状态登录

        我们之前可以使用shiro实现登录,但这些都是基于session或是cookie实现的,这些只能用于单机部署的服务,或是分布式服务共享会话,显然后者开销极大,所以JWT(JSON Web Token)应运而生,JWT是一套约定好的认证协议,通过请求携带令牌来访问那些需鉴权的接口。

        我们在这里使用token,原理类似,但是规则更为简单,没有形式上的约束,只是在请求Head或是body中添加token用于校验用户身份,token是可以和会话共存的,此处我们使用Shiro的会话登录结合JWT来实现无状态登录,从而实现扫码登录和一般的接口访问授权。

        项目中,需要实现无状态登录(单点登录,SSO),但是同时也要保持Shiro本身自带的会话登录。

        Shiro本身并不适合做基于鉴权的无状态登录,更适合它的应用场景是单体应用,这点需要注意。

有关token

     此文中有关token和SSO的叙述比较粗略,只是大意阐释了流程和部分相关概念。

 token一般的校验流程

 长token与短token

        此处采用OAuth2.0的标准,多数服务都是客户端(浏览器)与服务端后台分离的令牌,前端用于确认用户身份的是短token(通常称access token),而实际保存用户信息(包含用户基本信息、角色、权限等)的是长token(通常称refresh token),只存在后台,以短token映射长token实现用户身份和权限校验。

        这里的长与短,不仅指token所存信息量的多少或是token文本的长度,还指token的有效时间,一般短token对应着会话,存活时间较短,临近过期若用户仍未登出会进行刷新。使用长短token的模式其实近似于 sessionid/uuid+内存或是nosql存储用户实体对象 的处理方案。

 JWT、token

        token相比之下是一个很广泛的定义,一切表示令牌的内容都可以称作是token,jwt算是token中相对很经典、适用范围很广的一套规范,它将用户访问令牌封装在Json格式的数据中,包含在每次请求中。java中相关的实现为auth0的包。

无状态登录与单点登录

        前文中所述的无状态登陆与单点登录还是有所不同的,常见安全框架(例如Shiro)的默认行为是有状态登录,他在cookie中存储了sessionId,同时后台中保存了sessionId对应的用户映射,这样对于每一个sessionId后台都记录了对应的登录信息,有状态的登录缺憾是在集群环境下需要进行session共享,而且功能的实际实现一般都是集成了安全框架,对外不可见,也不便于扩展(扩展可以尝试使用Spring Security安全框架,但是相较Shiro上手门槛较高)。

        而无状态登录就是本文中所说的token验证方式,相比于有状态登录信息默认存储在服务本地内存中,无状态的用户信息(token与信息映射)存储在服务公有资源中,服务的横向扩展是毫无压力的,同时也解决了服务多节点跳动的sessionId变化问题,但是需要考虑token的安全存储。

        总而言之,有状态服务的特点和优缺点为:

为什么需要SSO

        前文中所说的此网站扫码登录需要用到SSO,凭此将用户的登录状态由手机客户端转移至浏览器中,但是这种情况下的登录状态转移是SSO的落地实现,并不能很好的解释为什么要通过SSO实现它。例如,我们也可以将用户名和密码加密后直接传入扫码网站实现登录,但这么做显然是不合时宜的,主要因为:

  1. 安全性没有保障,不使用单点登录就要求用户端留存相关密码密钥,这样一来很容易直接泄露不更改便永久有效的密码,而不是一段会过期的token;

  2. 有些使用登录中心的应用跟客户端(浏览器)并不互相信任,例如对接第三方平台(QQ、微信等)实现登陆,第三方肯定不能将用户的全部信息传回,他们反而把整个登陆过程承包了下来,返回给我们的只是用户能对外公开的基本信息,或是一段id,每次都要请求第三方再校验;

业务处理

 假定的一般实现:不使用框架

        假如实现登录功能不使用框架(Shiro),而只在后台使用生成jwt的相关依赖,我们需要一片能被所有服务访问的公共存储维护用户的在线信息,最佳手段是采取诸如nosql的k-v存储,此处假定采用redis实现,那么需要在redis中存储如下数据:

        但是因为需要将用户的token存在前端,参考上文中的长短token,我们还需要存储用户到短token的映射,可将上面的用户标识直接更正为短token(或者可以使用自定义的sessionId,注意务必确保全局唯一)。

 无状态登录系统的标配

无状态登录系统标配的组件和逻辑有以下几种:

  1. 一套token和用户的映射关系;

  2. 一套针对用户密码的双向加密或是单向编码算法;

  3. 一个用户鉴权中心,可以是独立的服务;

  4. 一个用户信息存储介质(一般是noSQL);

可以基于Shiro开发鉴权中心,但是实际上不是很合适。

 流程设计

    基于以上的思想,设计使用Shiro的单点登录,整体方案的实施需要两个主要的模块,身份认证和安全控制,本篇身份认证采用jwt,安全控制采用Shiro框架。

流程设计如下:

实战

    关于基本使用请参考安全框架的使用:shiro——鱼鱼的Java小站,此处主要附Token的生成和基于Redis的存储。

    编写加密算法

@Service
public class PswEncodeService {
    //使用了SHA-1
    public String pswEncode(String text,String salt){
        return encode(text+salt,"SHA1",encodeCount(salt));
    }

    public boolean checkPsw(String text,String cipher,String salt){
        return checkcode(text+salt,cipher,"SHA1",encodeCount(salt));
    }

    /**
     * @param text 待加密明文
     * @param key 加密方式
     * @param x 递归加密次数
     *  采用地柜加密更加安全
     */
    public String encode(String text,String key,int x){
        if (text == null) {
            return null;
        }
            if(x>1){
                try {
                    MessageDigest messageDigest = MessageDigest.getInstance(key);
                    messageDigest.update(text.getBytes());
                    return encode(getFormattedText(messageDigest.digest()),key,x-1);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }else{
                return text;
            }
    }

    public String encode(String text,String key){
        return encode(text,key,1);
    }

    public boolean checkcode(String text,String cipher,String key,int x){
        if(encode(text,key,x).equals(cipher))
            return true;
        else
            return false;
    }

    /**
     * 按照既定的明文编码规则获取加密迭代次数 为1000——1999次不等
     * @param text key明文
     *
     */
    public int encodeCount(String text){
        int a=1024
        //可以设计不同的迭代次数
        return a;
    };

    private static final char[] HEX = {'0', '1', '2', '3', '4', '5',
            '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};


    private static String getFormattedText(byte[] bytes) {
        int len = bytes.length;
        StringBuilder buf = new StringBuilder(len * 2);
        // 把密文转换成十六进制的字符串形式
        for (int j = 0; j < len; j++) {
            buf.append(HEX[(bytes[j] >> 4) & 0x0f]);
            buf.append(HEX[bytes[j] & 0x0f]);
        }
        return buf.toString();
    }



}

Token生成与校验、存储

token的生成方法与校验,首先引入依赖:

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.1</version>
</dependency>

然后编写token类:

@Service
public class TokenService {
    // 过期时间60分钟
    private static final long EXPIRE_TIME = 60*60*1000;
    @Autowired
    private PswService pswService;
    @Autowired
    LoginLogMapper loginLogMapper;
    private Logger logger = LoggerFactory.getLogger(TokenService.class);

    /**
     * 校验token是否正确
     * @param token 密钥
     * @param secret 用户的密码
     * @return 是否正确
     */
    public boolean verify(String token, String username, String secret) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(pswEncodeFacade.pswEncode(secret,username));
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    /**
     * 获得token中的信息无需secret解密也能获得
     * @return token中包含的用户名
     */
    public String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);

            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    public String getUserId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("id").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成签名,60min后过期
     * @param username 用户名
     * @param secret 用户的密码
     * @return 超级加密的token
     */
    public String sign(String username, String secret,String id) {
        Date date = new Date(System.currentTimeMillis()+EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(pswEncodeFacade.pswEncode(secret,username));
        // 附带username信息,也可在token中直接标示更多信息
        return JWT.create()
                .withClaim("username", username)
                .withClaim("id",id)
                .withExpiresAt(date)
                .sign(algorithm);
    }


}

基于Redis存储Shiro会话

编写cacheManager:

@Component
public class RedisCacheManager4Shiro implements CacheManager {

    @Resource
    RedisTemplate<String, Object> redisTemplate;
    private static final Logger logger = LoggerFactory.getLogger(RedisCacheManager4Shiro.class);
    //@Resource RedisUtil redisUtil;

    @Override
    public <K, V> Cache<K, V> getCache(String arg0) throws CacheException {
        return new RedisCache<K, V>();
    }

    class RedisCache<K, V> implements Cache<K, V>{

        public RedisCache() {
            redisTemplate.boundHashOps(CACHE_KEY).expire(180, TimeUnit.MINUTES);
        }
        //Cache 前缀
        private static final String CACHE_KEY = "shiro_redis_subject";

        @Override
        public void clear() throws CacheException {
            redisTemplate.delete(CACHE_KEY);
        }

        private String toString(Object obj){
            if(obj instanceof String){
                return obj.toString();
            }else{
                return JSONObject.toJSONString(obj);
            }
        }

        @SuppressWarnings("unchecked")
        @Override
        public V get(K k) throws CacheException {
            logger.info("get field:{}", toString(k));
            return (V)redisTemplate.boundHashOps(CACHE_KEY).get(k);
        }

        @SuppressWarnings("unchecked")
        @Override
        public Set<K> keys() {
            logger.info("keys");
            return (Set<K>)redisTemplate.boundHashOps(CACHE_KEY).keys();
        }

        @Override
        public V put(K k, V v) throws CacheException {
            logger.info("put field:{}, value:{}", toString(k), toString(v));
            redisTemplate.boundHashOps(CACHE_KEY).put(k, v);
            return v;
        }

        @Override
        public V remove(K k) throws CacheException {
            logger.info("remove field:{}", toString(k));
            V v = get(k);
            redisTemplate.boundHashOps(CACHE_KEY).delete(k);
            return v;
        }

        @Override
        public int size() {
            int size = redisTemplate.boundHashOps(CACHE_KEY).size().intValue();
            logger.info("size:{}", size);
            return size;
        }

        @SuppressWarnings("unchecked")
        @Override
        public Collection<V> values() {
            logger.info("values");
            return (Collection<V>)redisTemplate.boundHashOps(CACHE_KEY).values();
        }

        public String getCacheKey() {
            return "RedisCache";
        }

    }

}

然后在我们配置时指定:

//配置核心安全事务管理器
@Bean(name = "securityManager")
public SecurityManager securityManager(//@Qualifier("sessionManager")WebSessionManager webSessionManager,
                                       @Qualifier("authRealm") AuthRealm authRealm,
                                       @Qualifier("redisCacheManager4Shiro") RedisCacheManager4Shiro redisCacheManager4Shiro) {
    DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
    manager.setCacheManager(redisCacheManager4Shiro);
    manager.setSessionManager(redisSessionManager);
    manager.setRealm(authRealm);
    return manager;
}



使用Shiro和token进行无状态登录2020-03-22鱼鱼

{{commentTitle}}

评论   ctrl+Enter 发送评论