.NET Core 2.0 基于 IdentityServer4 实现 SSO(Auth 端)

本文涉及的项目

Auth 端是整个 SSO 的核心。

基本逻辑:

  1. 采用 HybridAndClientCredentials 方式授权
  2. 用户未登录时,访问任何一个 SSO 的系统,都会自动跳转到 Auth 端进行登录
  3. 成功登录之后,再打开其他系统,都将自动登录(用户无感知)
  4. 从任何一个系统中注销后,其它相关的系统也将一并注销
  5. 登录页面:各个系统中没有登录页面,统一跳转到 Auth 端去登录
  6. 注销页面:各个系统中有自己的注销页面,注销后,IdentityServer4 会自动触发 Auth 端的注销页面,在 Auth 端进行注销

建立好项目之后,先添加 Nuget 包(基于.NET Core 2.0.3):

dotnet add package IdentityServer4 --Version 2.1.0

// 也可以不安装上面那个包,直接安装这个(因为此包已包含上面的包)
// 若未安装上面的包,可能此包中包含的 IdentityServer4 不是 2.1.0
dotnet add package IdentityServer4.AspNetIdentity --version 2.0.0

在开始之前,建议将本项目的属性调整成控制台应用,这样,方便看到各种 Log,方便调试,如下图所示(注意图中红框的地方就好了):

调试时可以这样做,但是真正发布的时候,还是建议做一个 SSO 首页。

配置 appsettings.json 文件(请自行设计 json 结构)

// appsettings.json
"SSO": {
  "Apis": {
    "Count": 1,
    "Items": [
      {
        "name": "api_1",
        "displayName": "API 1"
      }
    ]
  },
  "Clients": {
    "Count": 2,
    "Items": [
      {
        "Id": "web_1",
        "Name": "Portal 1",
        "Secret": "c28a936b089340d9948efb788741e6e4c892ef273f9a40ed91814f3f6f76e4c0",
        "Urls": "https://localhost:30001,http://localhost:30001",
        "ApiNames": "api_1"
      },
      {
        "Id": "web_2",
        "Name": "Portal 2",
        "Secret": "7f201132c6f74ef185b55a5f143bf1ca9bfbb22a209e456b9645966a143cfa62",
        "Urls": "https://localhost:30002,http://localhost:30002",
        "ApiNames": "api_1"
      }
    ]
  }
}

新建一个 Config.cs 文件
在里面设置:IdentityResourceApiResourceClient

// SSOConfig.cs
// 本文将所有的配置文件,都写在 appsettings.json 中,也可以放在数据库或 Redis 中,总之,就是不在代码里面 hard code
public class SSOConfig
{
    public static IEnumerable<IdentityResource> IdentityResources => new IdentityResource[]
    {
        new IdentityResources.OpenId(),
        new IdentityResources.Profile()
    };

    public static IEnumerable<ApiResource> GetApiResources(IConfigurationSection section)
    {
        var apis = new List<ApiResource>();
        var prefix = "Apis:Items:{0}:";
        var max = int.Parse(section["Apis:Count"]);

        for (var i = 0; i < max; i++)
        {
            apis.Add(new ApiResource(section[string.Format(prefix, i) + "name"],
                                     section[string.Format(prefix, i) + "displayName"]));
        }

        return apis;
    }

    public static IEnumerable<Client> GetClients(IConfigurationSection section)
    {
        var clients = new List<Client>();
        var prefix = "Clients:Items:{0}:";
        string[] arrUrl;

        var scopes = new List<string>
        {
            IdentityServerConstants.StandardScopes.OpenId,
            IdentityServerConstants.StandardScopes.Profile
        };

        var max = int.Parse(section["Clients:Count"]);

        for (var i = 0; i < max; i++)
        {
            arrUrl = section[string.Format(prefix, i) + "Urls"].Split(',');

            Array.ForEach(section[string.Format(prefix, i) + "ApiNames"].Split(','), per =>
            {
                if (!scopes.Contains(per))
                {
                    scopes.Add(per);
                }
            });

            clients.Add(new Client
            {
                ClientId = section[string.Format(prefix, i) + "Id"],
                ClientName = section[string.Format(prefix, i) + "Name"],
                ClientSecrets = { new Secret(section[string.Format(prefix, i) + "Secret"].Sha256()) },

                AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
                RequireConsent = false,

                RedirectUris = arrUrl.Select(per => per + "/signin-oidc").ToArray(),
                PostLogoutRedirectUris = arrUrl.Select(per => per + "/signout-callback-oidc").ToArray(),

                AllowedScopes = scopes,

                // AlwaysIncludeUserClaimsInIdToken = true,
                AllowOfflineAccess = true
            });
        }

        return clients;
    }
}

修改 Startup.cs 文件

// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    // 采用自己的用户和角色表,用 Dapper 进行访问
    services.AddIdentity<MKUserInfo, MKRoleInfo>()
            .AddUserStore<MKUserStore>()
            .AddRoleStore<MKRoleStore>()
            .AddDefaultTokenProviders();

    // 很多采用的是下面这个方法,本文没有这样用
    // services.AddTransient<IUserStore<MKUserInfo>, MKUserStore>();
    // services.AddTransient<IRoleStore<MKRoleInfo>, MKRoleStore>();

    services.AddMvc();

    var section = Configuration.GetSection("SSO");

    services.AddIdentityServer()
            .AddDeveloperSigningCredential(filename: "tmpKey.rsa")
            .AddInMemoryIdentityResources(SSOConfig.IdentityResources)
            .AddInMemoryApiResources(SSOConfig.GetApiResources(section))
            .AddInMemoryClients(SSOConfig.GetClients(section))
            .AddAspNetIdentity<MKUserInfo>();

    // IdentityServer4 默认的登录地址是:/account/login
    // 如果不想使用默认的地址,可以将上面一段改为如下配置
    //services.AddIdentityServer(opts =>
    //        {
    //            opts.UserInteraction = new UserInteractionOptions
    //            {
    //                LoginUrl = "你想要的地址,默认:/account/login",
    //                LoginReturnUrlParameter = "你想要的返回页的参数名,默认:returnUrl"
    //            };
    //        })
    //        .AddDeveloperSigningCredential(filename: "tmpKey.rsa")
    //        .AddInMemoryIdentityResources(SSOConfig.IdentityResources)
    //        .AddInMemoryApiResources(SSOConfig.GetApiResources(section))
    //        .AddInMemoryClients(SSOConfig.GetClients(section))
    //        .AddAspNetIdentity<MKUserInfo>();

    // 此处是防止 CSRF 攻击的 Token 相关的名称(不采用默认名称)
    services.AddAntiforgery(opts =>
    {
        opts.Cookie.Name = "_mk_x_c_token";
        opts.FormFieldName = "_mk_x_f_token";
        opts.HeaderName = "_mk_x_h_token";
    });
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseBrowserLink();
    }
    else
    {
        app.UseExceptionHandler("/Error");
    }

    app.UseStaticFiles().UseIdentityServer();

    app.UseMvc(routes =>
    {
        routes.MapRoute(name: "default", template: "{controller}/{action=Index}/{id?}");
    });
}

上面涉及到了采用自己的用户(MKUserInfoMKUserStore)和角色(MKRoleInfoMKRoleStore),所以,自己实现了数据库的访问,并且没有采用 EF 之类的 ORM,直接使用 Dapper,完全自定义,只是想探寻一种可行性,毕竟,多一个选择嘛。

要实现自己的 MKUserInfo,需继承 IdentityUser 这个类(也可继承 IdentityUser<TKey>,不要问为什么,F12 进去看看就知道原因了)。

// MKUserInfo.cs
// 增加自定义需要的属性
public class MKUserInfo : IdentityUser<ulong>
{
    public string Salt { get; set; }   // 登录密码的加密参数

    public string TrueName { get; set; }

    public uint DeptID { get; set; }
}

添加了 MKUserInfo 之后,剩下的工作,就是访问数据库了。根据不同的条件,从数据库中获取到用户,并进行自定义登录逻辑的判断。这时,就需要自定义一个 MKUserStore 了。这个类需要继承三个接口,分别是:IUserStore<TUser>IUserPasswordStore<TUser>IUserEmailStore<TUser>

// MKUserStore.cs
// 这里只是做了简单处理,没有用到的方法,就没有进行实现
// 可根据相关方法自行处理,根据方法名称就能大概判断出各个方法的用途了吧
public class MKUserStore : IUserStore<MKUserInfo>, IUserPasswordStore<MKUserInfo>, IUserEmailStore<MKUserInfo>
{
    public Task<IdentityResult> CreateAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<IdentityResult> DeleteAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    // 这里不要 throw Exception,置空就好
    public void Dispose()
    { }

    public Task<MKUserInfo> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public async Task<MKUserInfo> FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested();

        // 根据 ID 找用户
        // 这里就可以进行数据库的访问了
        // 本文就简单处理了,直接返回了一个用户对象
        return await Task.Run(() =>
        {
            return new MKUserInfo
            {
                Id = 122333,
                UserName = "admin",   // 一定要给这个属性赋值,否则会报 value cannot be null
                Salt = "222",
                TrueName = "张三三",
                PasswordHash = "111222",
                DeptID = 2
            };
        });
    }

    public async Task<MKUserInfo> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested();

        // 根据 Username 找用户
        // 同样,这里也是进行数据库的访问
        // 本文就简单处理了,直接返回了一个用户对象
        return await Task.Run(() =>
        {
            return new MKUserInfo
            {
                Id = 122333,
                UserName = "admin",   // 一定要给这个属性赋值,否则会报 value cannot be null
                Salt = "222",
                TrueName = "张三三",
                PasswordHash = "111222",
                DeptID = 2
            };
        });
    }

    public async Task<string> GetEmailAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }

        return await Task.FromResult(user.Email);
    }

    public Task<bool> GetEmailConfirmedAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<string> GetNormalizedEmailAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<string> GetNormalizedUserNameAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public async Task<string> GetPasswordHashAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }

        return await Task.FromResult(user.PasswordHash);
    }

    public async Task<string> GetUserIdAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }

        return await Task.FromResult(user.Id.ToString());
    }

    public async Task<string> GetUserNameAsync(MKUserInfo user, CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }

        return await Task.FromResult(user.UserName);
    }

    public Task<bool> HasPasswordAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetEmailAsync(MKUserInfo user, string email, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetEmailConfirmedAsync(MKUserInfo user, bool confirmed, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetNormalizedEmailAsync(MKUserInfo user, string normalizedEmail, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetNormalizedUserNameAsync(MKUserInfo user, string normalizedName, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetPasswordHashAsync(MKUserInfo user, string passwordHash, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetUserNameAsync(MKUserInfo user, string userName, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<IdentityResult> UpdateAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

同理,MKRoleInfoMKRoleStore 的实现也可参考上面的方式进行,这里就直接贴代码了。

// MKRoleInfo.cs
public class MKRoleInfo : IdentityRole<uint>
{
}
// MKRoleStore.cs
public class MKRoleStore : IRoleStore<MKRoleInfo>
{
    public Task<IdentityResult> CreateAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<IdentityResult> DeleteAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    // 同样,这里也不要 throw Exception,置空就好
    public void Dispose()
    { }

    public Task<MKRoleInfo> FindByIdAsync(string roleId, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<MKRoleInfo> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<string> GetNormalizedRoleNameAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<string> GetRoleIdAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<string> GetRoleNameAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetNormalizedRoleNameAsync(MKRoleInfo role, string normalizedName, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task SetRoleNameAsync(MKRoleInfo role, string roleName, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public Task<IdentityResult> UpdateAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

上面完成之后,Auth 端的工作就完成了一大半了,接下来,就是添加登录和注销的方法了。

哦,对了,关于密码验证的逻辑!!!

.NET Identity 的密码验证比较复杂,感兴趣的可以自己上 Github 去看源码,本文就简单实现了,如下所示:

// MKPassword.cs
public class MKPassword : IPasswordHasher<MKUserInfo>
{
    public string HashPassword(MKUserInfo user, string password)
    {
        throw new NotImplementedException();
    }

    public PasswordVerificationResult VerifyHashedPassword(MKUserInfo user, string hashedPassword, string providedPassword)
    {
        // 这里用自己的逻辑进行验证即可,注意返回值就好了
        // 这里就简单处理了
        var newPass = providedPassword + user.Salt;
        if (hashedPassword == newPass)
        {
            return PasswordVerificationResult.Success;
        }

        return PasswordVerificationResult.Failed;
    }
}

由于本文采用的是 IdentityServer4 的默认路径,所以,在 Auth 项目里面,新建一个 Account 文件夹,在里面添加 Login.cshtmlLogout.cshtml,如下图所示:

// Login.cshtml
<h2>Login</h2>

@if (User.Identity.IsAuthenticated)
{
    <dl>
        @foreach (var claim in User.Claims)
        {
            <dt>@claim.Type</dt>
            <dd>@claim.Value</dd>
        }
    </dl>
}
else
{
    <form>
        <input type="text" id="txt-login-name" value="admin" />
        <input type="password" id="txt-login-pass" value="111" />
        <button type="submit">登录</button>
    </form>
    <script type="text/javascript">var _backUrl = '@Model.BackUrl';</script>
}
// Login.cshtml.cs
public class LoginModel : PageModel
{
    public string BackUrl { get; set; }

    public async Task<IActionResult> OnGetAsync(string returnUrl)
    {
        await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

        if (User.Identity.IsAuthenticated)
        {
            return Redirect(returnUrl ?? "/");
        }

        BackUrl = returnUrl ?? "/";
        return Page();
    }
}
// Logout.cshtml
<h2>Logout</h2>
// Logout.cshtml.cs
public class LogoutModel : PageModel
{
    private readonly IIdentityServerInteractionService _interaction;
    private readonly SignInManager<MKUserInfo> _signInMgr;

    public LogoutModel(IIdentityServerInteractionService interaction, SignInManager<MKUserInfo> signInMgr)
    {
        _interaction = interaction;
        _signInMgr = signInMgr;
    }

    public async Task<IActionResult> OnGetAsync(string logoutId)
    {
        await _signInMgr.SignOutAsync();
        var logout = await _interaction.GetLogoutContextAsync(logoutId);
        if (!string.IsNullOrWhiteSpace(logout?.PostLogoutRedirectUri))
        {
            return Redirect(logout.PostLogoutRedirectUri);
        }

        return Redirect("/account/login");
    }
}

页面写好了之后,就是登录交互了,本文采用的是 jQueryajax 进行访问,所以,在 Auth 项目中,需要一个 Login api 进行登录操作(放心,Razor Page 可以和 MVC 混合存在,只要路径不搞乱就行),代码如下:

// LoginController.cs
[Produces("application/json")]
[Route("api/[controller]")]
public class LoginController : Controller
{
    private readonly UserManager<MKUserInfo> _userMgr;
    private readonly SignInManager<MKUserInfo> _signInMgr;

    public LoginController(UserManager<MKUserInfo> userMgr, SignInManager<MKUserInfo> signInMgr)
    {
        _userMgr = userMgr;
        _userMgr.PasswordHasher = new MKPassword();   // 自定义密码验证(代码见上)

        _signInMgr = signInMgr;
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<MKResult> Login([FromBody]UserLoginModel model)
    {
        var result = await _signInMgr.PasswordSignInAsync(model.LoginName, model.LoginPass, false, false);
        if (!result.Succeeded)
        {
            return new MKResult(400, "登录名称或密码错误");
        }

        return new MKResult();
    }
}

参数 UserLoginModel 很简单,就两个字段,如下:

// UserLoginModel.cs
public class UserLoginModel
{
    public string LoginName { get; set; }

    public string LoginPass { get; set; }
}

另外就是,简单写了一个通用返回类

// MKResult.cs
public class MKResult
{
    /// <summary>
    /// 返回代码(默认:200 表示操作成功)
    /// </summary>
    public int Code { get; set; }

    /// <summary>
    /// 返回消息(默认:操作成功)
    /// </summary>
    public string Msg { get; set; }

    /// <summary>
    /// 构造函数(默认 Code:200)
    /// </summary>
    /// <param name="code">返回代码(默认:200 表示操作成功)</param>
    /// <param name="msg">返回消息(默认:操作成功)</param>
    public MKResult(int code = 200, string msg = "操作成功")
    {
        Code = code;
        Msg = msg;
    }
}

接下来是 jQuery 代码

// jQuery 代码
<script type="text/javascript">
    // Url 处理
    function escape2Html(content) {
        var tags = { 'lt': '<', 'gt': '>', 'nbsp': ' ', 'amp': '&', 'quot': '"' };
        return content.replace(/&(lt|gt|nbsp|amp|quot);/ig, function (all, idx) { return tags[idx]; });
    };

    $(function () {
        // 测试 Api 访问
        $('#btn-test').on('click', function () {
            $.getJSON('http://localhost:30101/values', function (result) {
                console.log(result);
            });
        });

        // 提交登录
        $('form').on('submit', function () {
            var data = {};
            data.loginName = $('#txt-login-name').val();
            data.loginPass = $('#txt-login-pass').val();

            $.ajax({
                url: '/api/login',
                type: 'post',
                data: JSON.stringify(data),
                contentType: 'application/json',
                headers: {
                    '_mk_x_h_token': $('input[name="_mk_x_f_token"]').val()
                },
                success: function (result) {
                    if (result.code != 200) {
                        return;
                    }

                    // 登录成功后进行跳转
                    location.href = escape2Html(_backUrl);
                }
            });

            return false;
        });
    });
</script>

至此,Auth 端的全部功能已经实现完毕了,接下就是添加 Client 端和 Api 端了。

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

推荐阅读更多精彩内容