springboot+shiro+jwt

jwt简介

1.什么是JWT
JWT(Json Web Token),是一种工具,格式为XXXX.XXXX.XXXX的字符串,JWT以一种安全的方式在用户和服务器之间传递存放在JWT中的不敏感信息。
2.为什么要用JWT
设想这样一个场景,在我们登录一个网站之后,再把网页或者浏览器关闭,下一次打开网页的时候可能显示的还是登录的状态,不需要再次进行登录操作,通过JWT就可以实现这样一个用户认证的功能。当然使用Session可以实现这个功能,但是使用Session的同时也会增加服务器的存储压力,而JWT是将存储的压力分布到各个客户端机器上,从而减轻服务器的压力。
3.JWT长什么样子

JWT由3个子字符串组成,分别为Header,Payload以及Signature,结合JWT的格式即:Header.Payload.Signature。(Claim是描述Json的信息的一个Json,将Claim转码之后生成Payload)。

Header

Header是由以下这个格式的Json通过Base64编码(编码不是加密,是可以通过反编码的方式获取到这个原来的Json,所以JWT中存放的一般是不敏感的信息)生成的字符串,Header中存放的内容是说明编码对象是一个JWT以及使用“SHA-256”的算法进行加密(加密用于生成Signature)

{ 
"typ":"JWT", 
"alg":"HS256" 
} 

Claim

Claim是一个Json,Claim中存放的内容是JWT自身的标准属性,所有的标准属性都是可选的,可以自行添加,比如:JWT的签发者、JWT的接收者、JWT的持续时间等;同时Claim中也可以存放一些自定义的属性,这个自定义的属性就是在用户认证中用于标明用户身份的一个属性,比如用户存放在数据库中的id,为了安全起见,一般不会将用户名及密码这类敏感的信息存放在Claim中。将Claim通过Base64转码之后生成的一串字符串称作Payload。

{ 
"iss":"Issuer —— 用于说明该JWT是由谁签发的", 
"sub":"Subject —— 用于说明该JWT面向的对象", 
"aud":"Audience —— 用于说明该JWT发送给的用户", 
"exp":"Expiration Time —— 数字类型,说明该JWT过期的时间", 
"nbf":"Not Before —— 数字类型,说明在该时间之前JWT不能被接受与处理", 
"iat":"Issued At —— 数字类型,说明该JWT何时被签发", 
"jti":"JWT ID —— 说明标明JWT的唯一ID", 
"user-definde1":"自定义属性举例", 
"user-definde2":"自定义属性举例" 
} 

Signature

Signature是由Header和Payload组合而成,将Header和Claim这两个Json分别使用Base64方式进行编码,生成字符串Header和Payload,然后将Header和Payload以Header.Payload的格式组合在一起形成一个字符串,然后使用上面定义好的加密算法和一个密匙(这个密匙存放在服务器上,用于进行验证)对这个字符串进行加密,形成一个新的字符串,这个字符串就是Signature。
4.JWT实现认证的原理
服务器在生成一个JWT之后会将这个JWT会以Authorization : Bearer JWT 键值对的形式存放在cookies里面发送到客户端机器,在客户端再次访问收到JWT保护的资源URL链接的时候,服务器会获取到cookies中存放的JWT信息,首先将Header进行反编码获取到加密的算法,在通过存放在服务器上的密匙对Header.Payload 这个字符串进行加密,比对JWT中的Signature和实际加密出来的结果是否一致,如果一致那么说明该JWT是合法有效的,认证成功,否则认证失败。

shiro+jwt整合

导包

       <!--JWT-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.4.0</version>
        </dependency>

JwtToken

@Data
public class JwtToken implements AuthenticationToken {

    private String token;

    public JwtToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return null;
    }

    @Override
    public Object getCredentials() {
        return null;
    }
}

JwtUtil

public class JwtUtil {
    private static final long EXPIRE_TIME = 30 *60*1000;

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

    /**
     * 获得token中的信息无需secret解密也能获得
     *
     * @return token中包含的用户名
     */
    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成签名,5min后过期
     *
     * @param username 用户名
     * @param secret   用户的密码
     * @return 加密的token
     */
    public static String sign(String username, String secret) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(secret);
        // 附带username信息
        return JWT.create()
                .withClaim("username", username)
                .withExpiresAt(date)
                .sign(algorithm);
    }
}

shiro配置

之前在springboot+shiro中,配置了一个CustomRealm用于用户名密码登录时的验证

@Bean
    public CustomRealm customRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
        CustomRealm customRealm=new CustomRealm();
        customRealm.setCredentialsMatcher(hashedCredentialsMatcher);
        return  customRealm;
    }

现在再创建一个JwtRealm用于 jwt token的验证

    @Bean
    public JwtRealm jwtRealm() {
        JwtRealm jwtRealm=new JwtRealm();
        jwtRealm.setCredentialsMatcher(new JwTCredentialsMatcher());
        return  jwtRealm;
    }

其中的JwTCredentialsMatcher是需要自己实现的,跟controller登录不一样,shiro并没有实现JWT的Matcherm,代码如下:

@Slf4j
public class JwTCredentialsMatcher implements CredentialsMatcher {
    /**
     * Matcher中直接调用工具包中的verify方法即可
     */
    @Override
    public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
        String token = ((JwtToken)authenticationToken).getToken();
        Object stored = authenticationInfo.getCredentials();
        String salt = stored.toString();
        String username=authenticationInfo.getPrincipals().toString();
        try {
            Algorithm algorithm = Algorithm.HMAC256(salt);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            verifier.verify(token);
            return true;
        } catch (Exception e) {
            log.info("Token Error:{}", e.getMessage());
        }
        return false;
    }
}

JwtRealm

public class JwtRealm extends AuthorizingRealm {
    @Autowired
    private UserMapper userMapper;

    /**
     * 必须重写此方法,不然Shiro会报错
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return new SimpleAuthorizationInfo();
    }
    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = ((JwtToken)auth).getToken();
        // 解密获得username,用于和数据库进行对比
        String username = JwtUtil.getUsername(token);
        User userBean = userMapper.selectbyUsername(username);
        return new SimpleAuthenticationInfo(username, userBean.getPassword(), getName());
    }

}

这里的doGetAuthorizationInfo直接返回一个SimpleAuthorizationInfo;因为之前的CustomerRealm中验证通过后就不会调用这个方法了,直接跳过。

过滤器

之前整合shiro时使用的是shiro 默认的权限拦截 Filter,而因为 JWT 的整合,我们需要自定义自己的过滤器 JWTFilter,

@Slf4j
public class JwtFilter extends AccessControlFilter {
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        try {
            executeLogin(request, response);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization");
        System.out.println("----executeLogin------");
        if(token==null)
        {
            log.info("token为空");
            throw new Exception("token 为空");
        }
        else {
            JwtToken jwtToken = new JwtToken(token);
            // 提交给realm进行登入,如果错误他会抛出异常并被捕获
            getSubject(request, response).login(jwtToken);
            return true;
        }
    }
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletResponse httpResponse = WebUtils.toHttp(response);
        httpResponse.setCharacterEncoding("UTF-8");
        httpResponse.setContentType("application/json;charset=utf-8");
        httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        httpResponse.setHeader("error","验证失败");
        return false;
    }
}

我这里是继承AccessControlFilter 过滤器,重写了其中的isAccessAllowed()和onAccessDenied()方法。

以下是AccessControlFilter 源码:

public abstract class AccessControlFilter extends PathMatchingFilter {

    public static final String DEFAULT_LOGIN_URL = "/login.jsp";

    public static final String GET_METHOD = "GET";

    public static final String POST_METHOD = "POST";

    private String loginUrl = DEFAULT_LOGIN_URL;

    public String getLoginUrl() {
        return loginUrl;
    }
    void setLoginUrl(String loginUrl) {
        this.loginUrl = loginUrl;
    }
    protected Subject getSubject(ServletRequest request, ServletResponse response) {
        return SecurityUtils.getSubject();
    }
    protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;

    protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return onAccessDenied(request, response);
    }

    protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;

    public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, response, mappedValue);
    }

    protected boolean isLoginRequest(ServletRequest request, ServletResponse response) {
        return pathsMatch(getLoginUrl(), request);
    }

    protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
        saveRequest(request);
        redirectToLogin(request, response);
    }
    protected void saveRequest(ServletRequest request) {
        WebUtils.saveRequest(request);
    }
    protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
        String loginUrl = getLoginUrl();
        WebUtils.issueRedirect(request, response, loginUrl);
    }

}

调试的时候发现,首先进入的是onPreHandle函数,如果isAccessAllowed返回false,即认证失败,则会进入onAccessDenied方法中,如果不重写的话,就会redirect到之前shiroconfig中配置的loginUrl。这样可能就会有跨域的问题,就要重新设置header头。

配置好自定义的过滤器和jwtrealm之后,shiroconfig代码如下:

@Configuration
public class ShiroConfig {
    @Bean
    public RestShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        RestShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
        // 必须设置 SecurityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // setLoginUrl 如果不设置值,默认会自动寻找Web工程根目录下的"/login.jsp"页面 或 "/login" 映射
        shiroFilterFactoryBean.setLoginUrl("/notLogin");
        // 设置无权限时跳转的 url;
        shiroFilterFactoryBean.setUnauthorizedUrl("/notRole");
        // 设置拦截器
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        //filterChainDefinitionMap.put("/logout","anon");
        filterChainDefinitionMap.put("/guest/**","anon");
        filterChainDefinitionMap.put("/test/test3","authc");
        filterChainDefinitionMap.put("/test/**==POST","anon");
        filterChainDefinitionMap.put("/login","anon");
        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
        filterMap.put("jwt", new JwtFilter());
        //filterMap.put("role",new AnyRolesAuthorizationFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        filterChainDefinitionMap.put("/**", "jwt");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        System.out.println("Shiro拦截器工厂类注入成功");
        return shiroFilterFactoryBean;
    }

    /**
     * 注入 securityManager
     */
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置realm.
        //securityManager.setRealm(customRealm(hashedCredentialsMatcher()));
        securityManager.setRealms(Arrays.asList(customRealm(hashedCredentialsMatcher()),jwtRealm()));
        System.out.println("securityManager生成成功");
        return securityManager;
    }

    @Bean
    public Authenticator authenticator() {
        ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
        //设置两个Realm,一个用于用户登录验证和访问权限获取;一个用于jwt token的认证
        authenticator.setRealms(Arrays.asList(customRealm(hashedCredentialsMatcher()),jwtRealm()));
        //设置多个realm认证策略,一个成功即跳过其它的
        authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
        return authenticator;
    }

    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
        hashedCredentialsMatcher.setHashAlgorithmName("SHA-256");//散列算法:MD2、MD5、SHA-1、SHA-256、SHA-384、SHA-512等。
        hashedCredentialsMatcher.setHashIterations(1);//散列的次数,默认1次, 设置两次相当于 md5(md5(""));
        System.out.println("hasedCredentialMather:"+hashedCredentialsMatcher);
        return hashedCredentialsMatcher;
    }

    /**
     * 自定义身份认证 realm;
     * <p>
     * 必须写这个类,并加上 @Bean 注解,目的是注入 CustomRealm,
     * 否则会影响 CustomRealm类 中其他类的依赖注入
     */
    @Bean
    public CustomRealm customRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
        CustomRealm customRealm=new CustomRealm();
        customRealm.setCredentialsMatcher(hashedCredentialsMatcher);
        System.out.println("customrealm生成成功");
        System.out.println(customRealm);
        return  customRealm;
    }
    @Bean
    public JwtRealm jwtRealm() {
        JwtRealm jwtRealm=new JwtRealm();
        jwtRealm.setCredentialsMatcher(new JwTCredentialsMatcher());
        return  jwtRealm;
    }

    /**
     * 禁用session, 不保存用户登录状态。保证每次请求都重新认证。
     * 需要注意的是,如果用户代码里调用Subject.getSession()还是可以用session,如果要完全禁用,要配合下面的noSessionCreation的Filter来实现
     */
    @Bean
    protected SessionStorageEvaluator sessionStorageEvaluator(){
        DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        return sessionStorageEvaluator;
    }
}

自定义权限过滤器

在实际的项目中,对同一个url多个角色都有访问权限很常见,shiro默认的RoleFilter没有提供支持,比如上面的配置,如果我们配置成下面这样,那用户必须同时具备admin和manager权限才能访问,显然这个是不合理的。

filterChainDefinitionMap.put("/test/test3","roles["user","admin"]");

所以自己实现一个role filter,只要任何一个角色符合条件就通过,只需要重写AuthorizationFilter中两个方法就可以了:

public class AnyRolesAuthorizationFilter  extends AuthorizationFilter {

    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {
        Subject subject = getSubject(servletRequest, servletResponse);
        String[] rolesArray = (String[]) mappedValue;
        if (rolesArray == null || rolesArray.length == 0) { //没有角色限制,有权限访问
            return true;
        }
        for (String role : rolesArray) {
            if (subject.hasRole(role)) //若当前用户是rolesArray中的任何一个,则有权限访问
                return true;
        }
        return false;
    }
    /**
     * 权限校验失败,错误处理
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
        HttpServletResponse httpResponse = WebUtils.toHttp(response);
        httpResponse.setCharacterEncoding("UTF-8");
        httpResponse.setContentType("application/json;charset=utf-8");
        httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        httpResponse.setHeader("anyrole","anyrole");
        //httpResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);
        return false;
    }

}

有了自定义的过滤器后,就可以这样写了。

 filterChainDefinitionMap.put("/user/**","jwt,roles[user]");
Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
        filterMap.put("jwt", new JwtFilter());
        filterChainDefinitionMap.put("/user/**","jwt,role[user,admin]");
        shiroFilterFactoryBean.setFilters(filterMap);
        filterChainDefinitionMap.put("/**", "jwt");

表示需要登录认证,而且只要有user或者admin中的任意一个权限即可。

总结

大致流程为:前端发送请求,RestPathMatchingFilterChainResolver中判断匹配到哪一个过滤器。然后调用此过滤器,在过滤器中执行subject.login(token)进行验证,此时会跳转到realm中进行doGetAuthenticationInfo()身份认证和doGetAuthorizationInfo()权限认证,要是SimpleAuthenticationInfo调用Matcher方法抛出了异常,即subject.login(token)抛出了,则说明身份验证不通过。isAccessAllowed()返回false,进入onAccessDenied()中。如果SimpleAuthenticationInfo没有抛出异常,如果不需要权限认证,则请求成功,如果需要权限认证,就再调用doGetAuthorizationInfo()进行权限验证。

例子:
前端请求/user/1 (假设header中带有jwttoken)

filterChainDefinitionMap.put("/user/**","jwt,role[user,admin]");

RestPathMatchingFilterChainResolver匹配到"/user/**",先进入jwt过滤器中,调用isAccessAllowed(),里面执行
subject.login(token)进行验证,在jwtrealm中的doGetAuthenticationInfo(),验证通过。则再进入role过滤器中,继续验证。

在做项目的时候发现了一个bug:

就是当使用 filterChainDefinitionMap.put("/users/ * *==GET","jwt,role[admin]");进行权限校验时,有bug

rotected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {

        if (this.appliedPaths == null || this.appliedPaths.isEmpty()) {
            if (log.isTraceEnabled()) {
                log.trace("appliedPaths property is null or empty.  This Filter will passthrough immediately.");
            }
            return true;
        }

        for (String path : this.appliedPaths.keySet()) {
            // If the path does match, then pass on to the subclass implementation for specific checks
            //(first match 'wins'):
            if (pathsMatch(path, request)) {
                log.trace("Current requestURI matches pattern '{}'.  Determining filter chain execution...", path);
                Object config = this.appliedPaths.get(path);
                return isFilterChainContinued(request, response, path, config);
            }
        }

        //no path matched, allow the request to go through:
        return true;
    }
        filterChainDefinitionMap.put("/user==POST", "anon");
        filterChainDefinitionMap.put("/user/*==GET","jwt");
        filterChainDefinitionMap.put("/user/*==PUT", "role[user,superadmin]");//user/superadmin
        filterChainDefinitionMap.put("/users==GET","jwt,role[admin]");
        filterChainDefinitionMap.put("/**", "jwt");

查看源码可知,只有当path(即 filterChainDefinitionMap中所添加的url)和request中的url匹配时,才会进入权限校验,而此时path 为 “/users==GET",而request中是url为 /users,因此不会匹配到,就会返回true(即说明此url不需要进行权限校验);

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

推荐阅读更多精彩内容