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

  • 目录在此
  • Client 端在此
  • Api 端在此
.NET Core 2.0 基于 IdentityServer4 实现 SSO(Auth 端)_第1张图片
本文涉及的项目

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,方便调试,如下图所示(注意图中红框的地方就好了):

.NET Core 2.0 基于 IdentityServer4 实现 SSO(Auth 端)_第2张图片

调试时可以这样做,但是真正发布的时候,还是建议做一个 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 IdentityResources => new IdentityResource[]
    {
        new IdentityResources.OpenId(),
        new IdentityResources.Profile()
    };

    public static IEnumerable GetApiResources(IConfigurationSection section)
    {
        var apis = new List();
        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 GetClients(IConfigurationSection section)
    {
        var clients = new List();
        var prefix = "Clients:Items:{0}:";
        string[] arrUrl;

        var scopes = new List
        {
            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()
            .AddUserStore()
            .AddRoleStore()
            .AddDefaultTokenProviders();

    // 很多采用的是下面这个方法,本文没有这样用
    // services.AddTransient, MKUserStore>();
    // services.AddTransient, 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();

    // 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();

    // 此处是防止 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,不要问为什么,F12 进去看看就知道原因了)。

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

    public string TrueName { get; set; }

    public uint DeptID { get; set; }
}

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

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

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

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

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

    public async Task 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 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 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 GetEmailConfirmedAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

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

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

    public async Task 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 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 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 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 UpdateAsync(MKUserInfo user, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

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

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

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

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

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

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

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

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

    public Task 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 UpdateAsync(MKRoleInfo role, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

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

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

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

// MKPassword.cs
public class MKPassword : IPasswordHasher
{
    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

Login

@if (User.Identity.IsAuthenticated) {
@foreach (var claim in User.Claims) {
@claim.Type
@claim.Value
}
} else {
}
// Login.cshtml.cs
public class LoginModel : PageModel
{
    public string BackUrl { get; set; }

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

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

        BackUrl = returnUrl ?? "/";
        return Page();
    }
}
// Logout.cshtml

Logout

// Logout.cshtml.cs
public class LogoutModel : PageModel
{
    private readonly IIdentityServerInteractionService _interaction;
    private readonly SignInManager _signInMgr;

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

    public async Task 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 _userMgr;
    private readonly SignInManager _signInMgr;

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

        _signInMgr = signInMgr;
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task 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
{
    /// 
    /// 返回代码(默认:200 表示操作成功)
    /// 
    public int Code { get; set; }

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

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

接下来是 jQuery 代码

// jQuery 代码

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

  • 目录在此
  • Client 端在此
  • Api 端在此

你可能感兴趣的:(.NET Core 2.0 基于 IdentityServer4 实现 SSO(Auth 端))