3

.NET Core中JWT+Auth2.0实现SSO,附完整源码(.NET6) - 包子wxl

 1 year ago
source link: https://www.cnblogs.com/wei325/p/16316004.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

.NET Core中JWT+Auth2.0实现SSO,附完整源码(.NET6)



单点登录(SingleSignOn,SSO)

指的是在多个应用系统中,只需登录一次,就可以访问其他相互信任的应用系统。

JWT

Json Web Token,这里不详细描述,简单说是一种认证机制。

Auth2.0

Auth2.0是一个认证流程,一共有四种方式,这里用的是最常用的授权码方式,流程为:

1、系统A向认证中心先获取一个授权码code。

2、系统A通过授权码code获取 token,refresh_token,expiry_time,scope。

token:系统A向认证方获取资源请求时带上的token。

refresh_token:token的有效期比较短,用来刷新token用。

expiry_time:token过期时间。

scope:资源域,系统A所拥有的资源权限,比喻scope:["userinfo"],系统A只拥有获取用户信息的权限。像平时网站接入微信登录也是只能授权获取微信用户基本信息。

这里的SSO都是公司自己的系统,都是获取用户信息,所以这个为空,第三方需要接入我们的登录时才需要scope来做资源权限判断。

二、实现目标

1、一处登录,全部登录

流程图为:

630011-20220526233544954-1229936888.png

 1、浏览器访问A系统,发现A系统未登录,跳转到统一登录中心(SSO),带上A系统的回调地址,

地址为:https://sso.com/SSO/Login?redirectUrl=https://web1.com/Account/LoginRedirect&clientId=web1,输入用户名,密码,登录成功,生成授权码code,创建一个全局会话(cookie,redis),带着授权码跳转回A系统地址:https://web1.com/Account/LoginRedirect?AuthCode=xxxxxxxx。然后A系统的回调地址用这个AuthCode调用SSO获取token,获取到token,创建一个局部会话(cookie,redis),再跳转到https://web1.com。这样A系统就完成了登录。

2、浏览器访问B系统,发现B系统没登录,跳转到统一登录中心(SSO),带上B系统的回调地址,

地址为:https://sso.com/SSO/Login?redirectUrl=https://web2.com/Account/LoginRedirect&clientId=web2,SSO有全局会话证明已经登录过,直接用全局会话code获取B系统的授权码code,

带着授权码跳转回B系统https://web2.com/Account/LoginRedirect?AuthCode=xxxxxxxx,然后B系统的回调地址用这个AuthCode调用SSO获取token,获取到token创建一个局部会话(cookie,redis),再跳转到https://web2.com。整个过程不用输入用户名密码,这些跳转基本是无感的,所以B就自动登录好了。

为什么要多个授权码而不直接带token跳转回A,B系统呢?因为地址上的参数是很容易被拦截到的,可能token会被截取到,非常不安全

还有为了安全,授权码只能用一次便销毁,A系统的token和B系统的token是独立的,不能相互访问。

2、一处退出,全部退出

流程图为:

630011-20220527001225168-1901767920.png

 A系统退出,把自己的会话删除,然后跳转到SSO的退出登录地址:https://sso.com/SSO/Logout?redirectUrl=https://web1.com/Account/LoginRedirect&clientId=web1,SSO删除全局会话,然后调接口删除获取了token的系统,然后在跳转到登录页面,https://sso.com/SSO/Login?redirectUrl=https://web1.com/Account/LoginRedirect&clientId=web1,这样就实现了一处退出,全部退出了。

3、双token机制

也就是带刷新token,为什么要刷新token呢?因为基于token式的鉴权授权有着天生的缺陷

token设置时间长,token泄露了,重放攻击。

token设置短了,老是要登录。问题还有很多,因为token本质决定,大部分是解决不了的。

所以就需要用到双Token机制,SSO返回token和refreshToken,token用来鉴权使用,refreshToken刷新token使用,

比喻token有效期10分钟,refreshToken有效期2天,这样就算token泄露了,最多10分钟就会过期,影响没那么大,系统定时9分钟刷新一次token,

这样系统就能让token滑动过期了,避免了频繁重新登录。

 三、功能实现和核心代码

1、一处登录,全部登录实现

建三个项目,SSO的项目,web1的项目,web2项目。

这里的流程就是web1跳转SSO输用户名登录成功获取code,把会话写到SSO的cookie,然后跳转回来根据code跟SSO获取token登录成功;

然后访问web2跳转到SSO,SSO已经登录,自动获取code跳回web2根据code获取token。

能实现一处登录处处登录的关键是SSO的cookie。

然后这里有一个核心的问题,如果我们生成的token有效期都是24小时,那么web1登录成功,获取的token有效期是24小时,

等到过了12个小时,我访问web2,web2也得到一个24小时的token,这样再过12小时,web1的登录过期了,web2还没过期,

这样就是web2是登录状态,然而web1却不是登录状态需要重新登录,这样就违背了一处登录处处登录的理念。

所以后面获取的token,只能跟第一次登录的token的过期时间是一样的。怎么做呢,就是SSO第一次登录时过期时间缓存下来,后面根据SSO会话获取的code,

换到的token的过期时间都和第一次一样。

SSO项目

SSO项目配置文件appsettings.json中加入web1,web2的信息,用来验证来源和生成对应项目的jwt token,实际项目应该存到数据库。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AppSetting": {
    "appHSSettings": [
      {
        "domain": "https://localhost:7001",
        "clientId": "web1",
        "clientSecret": "Nu4Ohg8mfpPnNxnXu53W4g0yWLqF0mX2"
      },
      {
        "domain": "https://localhost:7002",
        "clientId": "web2",
        "clientSecret": "pQeP5X9wejpFfQGgSjyWB8iFdLDGHEV8"
      }

    ]
  }
 
}

domain:接入系统的域名,可以用来校验请求来源是否合法。

clientId:接入系统标识,请求token时传进来识别是哪个系统。

clientSecret:接入系统密钥,用来生成对称加密的JWT。

建一个IJWTService定义JWT生成需要的方法

 /// <summary>
    /// JWT服务接口
    /// </summary>
    public interface IJWTService
    {
        /// <summary>
        /// 获取授权码
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="password"></param>
        /// <returns></returns>
        /// <exception cref="NotImplementedException"></exception>
         ResponseModel<string> GetCode(string clientId, string userName, string password);
        /// <summary>
        /// 根据会话Code获取授权码
        /// </summary>
        /// <param name="clientId"></param>
        /// <param name="sessionCode"></param>
        /// <returns></returns>
        ResponseModel<string> GetCodeBySessionCode(string clientId, string sessionCode);

        /// <summary>
        /// 根据授权码获取Token+RefreshToken
        /// </summary>
        /// <param name="authCode"></param>
        /// <returns>Token+RefreshToken</returns>
        ResponseModel<GetTokenDTO> GetTokenWithRefresh(string authCode);

        /// <summary>
        /// 根据RefreshToken刷新Token
        /// </summary>
        /// <param name="refreshToken"></param>
        /// <param name="clientId"></param>
        /// <returns></returns>
        string GetTokenByRefresh(string refreshToken, string clientId);
    }

建一个抽象类JWTBaseService加模板方法实现详细的逻辑

 /// <summary>
    /// jwt服务
    /// </summary>
    public abstract class JWTBaseService : IJWTService
    {
        protected readonly IOptions<AppSettingOptions> _appSettingOptions;
        protected readonly Cachelper _cachelper;
        public JWTBaseService(IOptions<AppSettingOptions> appSettingOptions, Cachelper cachelper)
        {
            _appSettingOptions = appSettingOptions;
            _cachelper = cachelper;
        }

        /// <summary>
        /// 获取授权码
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="password"></param>
        /// <returns></returns>
        /// <exception cref="NotImplementedException"></exception>
        public ResponseModel<string> GetCode(string clientId, string userName, string password)
        {
            ResponseModel<string> result = new ResponseModel<string>();

            string code = string.Empty;
            AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault();
            if (appHSSetting == null)
            {
                result.SetFail("应用不存在");
                return result;
            }
            //真正项目这里查询数据库比较
            if (!(userName == "admin" && password == "123456"))
            {
                result.SetFail("用户名或密码不正确");
                return result;
            }

            //用户信息
            CurrentUserModel currentUserModel = new CurrentUserModel
            {
                id = 101,
                account = "admin",
                name = "张三",
                mobile = "13800138000",
                role = "SuperAdmin"
            };

            //生成授权码
            code = Guid.NewGuid().ToString().Replace("-", "").ToUpper();
            string key = $"AuthCode:{code}";
            string appCachekey = $"AuthCodeClientId:{code}";
            //缓存授权码
            _cachelper.StringSet<CurrentUserModel>(key, currentUserModel, TimeSpan.FromMinutes(10));
            //缓存授权码是哪个应用的
            _cachelper.StringSet<string>(appCachekey, appHSSetting.clientId, TimeSpan.FromMinutes(10));
            //创建全局会话
            string sessionCode = $"SessionCode:{code}";
            SessionCodeUser sessionCodeUser = new SessionCodeUser
            {
                expiresTime = DateTime.Now.AddHours(1),
                currentUser = currentUserModel
            };
            _cachelper.StringSet<CurrentUserModel>(sessionCode, currentUserModel, TimeSpan.FromDays(1));
            //全局会话过期时间
            string sessionExpiryKey = $"SessionExpiryKey:{code}";
            DateTime sessionExpirTime = DateTime.Now.AddDays(1);
            _cachelper.StringSet<DateTime>(sessionExpiryKey, sessionExpirTime, TimeSpan.FromDays(1));
            Console.WriteLine($"登录成功,全局会话code:{code}");
            //缓存授权码取token时最长的有效时间
            _cachelper.StringSet<DateTime>($"AuthCodeSessionTime:{code}", sessionExpirTime, TimeSpan.FromDays(1));

            result.SetSuccess(code);
            return result;
        }
        /// <summary>
        /// 根据会话code获取授权码
        /// </summary>
        /// <param name="clientId"></param>
        /// <param name="sessionCode"></param>
        /// <returns></returns>
        public ResponseModel<string> GetCodeBySessionCode(string clientId, string sessionCode)
        {
            ResponseModel<string> result = new ResponseModel<string>();
            string code = string.Empty;
            AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault();
            if (appHSSetting == null)
            {
                result.SetFail("应用不存在");
                return result;
            }
            string codeKey = $"SessionCode:{sessionCode}";
            CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>(codeKey);
            if (currentUserModel == null)
            {
                return result.SetFail("会话不存在或已过期", string.Empty);
            }

            //生成授权码
            code = Guid.NewGuid().ToString().Replace("-", "").ToUpper();
            string key = $"AuthCode:{code}";
            string appCachekey = $"AuthCodeClientId:{code}";
            //缓存授权码
            _cachelper.StringSet<CurrentUserModel>(key, currentUserModel, TimeSpan.FromMinutes(10));
            //缓存授权码是哪个应用的
            _cachelper.StringSet<string>(appCachekey, appHSSetting.clientId, TimeSpan.FromMinutes(10));

            //缓存授权码取token时最长的有效时间
            DateTime expirTime = _cachelper.StringGet<DateTime>($"SessionExpiryKey:{sessionCode}");
            _cachelper.StringSet<DateTime>($"AuthCodeSessionTime:{code}", expirTime, expirTime - DateTime.Now);

            result.SetSuccess(code);
            return result;

        }

        /// <summary>
        /// 根据刷新Token获取Token
        /// </summary>
        /// <param name="refreshToken"></param>
        /// <param name="clientId"></param>
        /// <returns></returns>
        public string GetTokenByRefresh(string refreshToken, string clientId)
        {
            //刷新Token是否在缓存
            CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>($"RefreshToken:{refreshToken}");
            if(currentUserModel==null)
            {
                return String.Empty;
            }
            //刷新token过期时间
            DateTime refreshTokenExpiry = _cachelper.StringGet<DateTime>($"RefreshTokenExpiry:{refreshToken}");
            //token默认时间为600s
            double tokenExpiry = 600;
            //如果刷新token的过期时间不到600s了,token过期时间为刷新token的过期时间
            if(refreshTokenExpiry>DateTime.Now&&refreshTokenExpiry<DateTime.Now.AddSeconds(600))
            {
                tokenExpiry = (refreshTokenExpiry - DateTime.Now).TotalSeconds;
            }

                //从新生成Token
                string token = IssueToken(currentUserModel, clientId, tokenExpiry);
                return token;

        }

        /// <summary>
        /// 根据授权码,获取Token
        /// </summary>
        /// <param name="userInfo"></param>
        /// <param name="appHSSetting"></param>
        /// <returns></returns>
        public ResponseModel<GetTokenDTO> GetTokenWithRefresh(string authCode)
        {
            ResponseModel<GetTokenDTO> result = new ResponseModel<GetTokenDTO>();

            string key = $"AuthCode:{authCode}";
            string clientIdCachekey = $"AuthCodeClientId:{authCode}";
            string AuthCodeSessionTimeKey = $"AuthCodeSessionTime:{authCode}";

            //根据授权码获取用户信息
            CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>(key);
            if (currentUserModel == null)
            {
                throw new Exception("code无效");
            }
            //清除authCode,只能用一次
            _cachelper.DeleteKey(key);

            //获取应用配置
            string clientId = _cachelper.StringGet<string>(clientIdCachekey);
            //刷新token过期时间
            DateTime sessionExpiryTime = _cachelper.StringGet<DateTime>(AuthCodeSessionTimeKey);
            DateTime tokenExpiryTime = DateTime.Now.AddMinutes(10);//token过期时间10分钟
             //如果刷新token有过期期比token默认时间短,把token过期时间设成和刷新token一样
            if (sessionExpiryTime > DateTime.Now && sessionExpiryTime < tokenExpiryTime)
            {
                tokenExpiryTime = sessionExpiryTime;
            }
            //获取访问token
            string token = this.IssueToken(currentUserModel, clientId, (sessionExpiryTime - DateTime.Now).TotalSeconds);


            TimeSpan refreshTokenExpiry;
            if (sessionExpiryTime != default(DateTime))
            {
                refreshTokenExpiry = sessionExpiryTime - DateTime.Now;
            }
            else
            {
                refreshTokenExpiry = TimeSpan.FromSeconds(60 * 60 * 24);//默认24小时
            }
            //获取刷新token
            string refreshToken = this.IssueToken(currentUserModel, clientId, refreshTokenExpiry.TotalSeconds);
            //缓存刷新token
            _cachelper.StringSet($"RefreshToken:{refreshToken}", currentUserModel, refreshTokenExpiry);
            //缓存刷新token过期时间
            _cachelper.StringSet($"RefreshTokenExpiry:{refreshToken}",DateTime.Now.AddSeconds(refreshTokenExpiry.TotalSeconds), refreshTokenExpiry);
            result.SetSuccess(new GetTokenDTO() { token = token, refreshToken = refreshToken, expires = 60 * 10 });
            Console.WriteLine($"client_id:{clientId}获取token,有效期:{sessionExpiryTime.ToString("yyyy-MM-dd HH:mm:ss")},token:{token}");
            return result;
        }

        #region private
        /// <summary>
        /// 签发token
        /// </summary>
        /// <param name="userModel"></param>
        /// <param name="clientId"></param>
        /// <param name="second"></param>
        /// <returns></returns>
        private string IssueToken(CurrentUserModel userModel, string clientId, double second = 600)
        {
            var claims = new[]
            {
                   new Claim(ClaimTypes.Name, userModel.name),
                   new Claim("Account", userModel.account),
                   new Claim("Id", userModel.id.ToString()),
                   new Claim("Mobile", userModel.mobile),
                   new Claim(ClaimTypes.Role,userModel.role),
            };
            //var appHSSetting = getAppInfoByAppKey(clientId);
            //var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appHSSetting.clientSecret));
            //var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            var creds = GetCreds(clientId);
            /**
             * Claims (Payload)
                Claims 部分包含了一些跟这个 token 有关的重要信息。 JWT 标准规定了一些字段,下面节选一些字段:
                iss: The issuer of the token,签发主体,谁给的
                sub: The subject of the token,token 主题
                aud: 接收对象,给谁的
                exp: Expiration Time。 token 过期时间,Unix 时间戳格式
                iat: Issued At。 token 创建时间, Unix 时间戳格式
                jti: JWT ID。针对当前 token 的唯一标识
                除了规定的字段外,可以包含其他任何 JSON 兼容的字段。
             * */
            var token = new JwtSecurityToken(
                issuer: "SSOCenter", //谁给的
                audience: clientId, //给谁的
                claims: claims,
                expires: DateTime.Now.AddSeconds(second),//token有效期
                notBefore: null,//立即生效  DateTime.Now.AddMilliseconds(30),//30s后有效
                signingCredentials: creds);
            string returnToken = new JwtSecurityTokenHandler().WriteToken(token);
            return returnToken;
        }

        /// <summary>
        /// 根据appKey获取应用信息
        /// </summary>
        /// <param name="clientId"></param>
        /// <returns></returns>
        private AppHSSetting getAppInfoByAppKey(string clientId)
        {
            AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault();
            return appHSSetting;
        }
        /// <summary>
        /// 获取加密方式
        /// </summary>
        /// <returns></returns>
        protected abstract SigningCredentials GetCreds(string clientId);
        
        #endregion
    }

新建类JWTHSService实现对称加密

 /// <summary>
    /// JWT对称可逆加密
    /// </summary>
    public class JWTHSService : JWTBaseService
    {
        public JWTHSService(IOptions<AppSettingOptions> options, Cachelper cachelper):base(options,cachelper)
        {

        }
        /// <summary>
        /// 生成对称加密签名凭证
        /// </summary>
        /// <param name="clientId"></param>
        /// <returns></returns>
        protected override SigningCredentials GetCreds(string clientId)
        {
           var appHSSettings=getAppInfoByAppKey(clientId);
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appHSSettings.clientSecret));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            return creds;
        }
        /// <summary>
        /// 根据appKey获取应用信息
        /// </summary>
        /// <param name="clientId"></param>
        /// <returns></returns>
        private AppHSSetting getAppInfoByAppKey(string clientId)
        {
            AppHSSetting appHSSetting = _appSettingOptions.Value.appHSSettings.Where(s => s.clientId == clientId).FirstOrDefault();
            return appHSSetting;
        }
       
    }

新建JWTRSService类实现非对称加密,和上面的对称加密,只需要一个就可以里,这里把两种都写出来了

/// <summary>
    /// JWT非对称加密
    /// </summary>
    public class JWTRSService : JWTBaseService
    {
  
        public JWTRSService(IOptions<AppSettingOptions> options, Cachelper cachelper):base(options, cachelper)
        {
 
        }
        /// <summary>
        /// 生成非对称加密签名凭证
        /// </summary>
        /// <param name="clientId"></param>
        /// <returns></returns>
        protected override SigningCredentials GetCreds(string clientId)
        {
            var appRSSetting = getAppInfoByAppKey(clientId);
            var rsa = RSA.Create();
            byte[] privateKey = Convert.FromBase64String(appRSSetting.privateKey);//这里只需要私钥,不要begin,不要end
            rsa.ImportPkcs8PrivateKey(privateKey, out _);
            var key = new RsaSecurityKey(rsa);
            var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
            return creds;
        }
        /// <summary>
        /// 根据appKey获取应用信息
        /// </summary>
        /// <param name="clientId"></param>
        /// <returns></returns>
        private AppRSSetting getAppInfoByAppKey(string clientId)
        {
            AppRSSetting appRSSetting = _appSettingOptions.Value.appRSSettings.Where(s => s.clientId == clientId).FirstOrDefault();
            return appRSSetting;
        }

    }

什么时候用JWT的对称加密,什么时候用JWT的非对称加密呢?

对称加密:双方保存同一个密钥,签名速度快,但因为双方密钥一样,所以安全性比非对称加密低一些。

非对称加密:认证方保存私钥,系统方保存公钥,签名速度比对称加密慢,但公钥私钥互相不能推导,所以安全性高。

所以注重性能的用对称加密,注重安全的用非对称加密,一般是公司的系统用对称加密,第三方接入的话用非对称加密。

web1项目:

appsettings.json存着web1的信息

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "SSOSetting": {
    "issuer": "SSOCenter",
    "audience": "web1",
    "clientId": "web1",
    "clientSecret": "Nu4Ohg8mfpPnNxnXu53W4g0yWLqF0mX2"
  }
}

 Program.cs文件加入认证代码,加入builder.Services.AddAuthentication(。。。和加入app.UseAuthentication(),完整代码如下:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using RSAExtensions;
using SSO.Demo.Web1.Models;
using SSO.Demo.Web1.Utils;
using System.Security.Cryptography;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<Cachelper>();
builder.Services.Configure<AppOptions>(builder.Configuration.GetSection("AppOptions"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            //Audience,Issuer,clientSecret的值要和sso的一致

            //JWT有一些默认的属性,就是给鉴权时就可以筛选了
            ValidateIssuer = true,//是否验证Issuer
            ValidateAudience = true,//是否验证Audience
            ValidateLifetime = true,//是否验证失效时间
            ValidateIssuerSigningKey = true,//是否验证client secret
            ValidIssuer = builder.Configuration["SSOSetting:issuer"],//
            ValidAudience = builder.Configuration["SSOSetting:audience"],//Issuer,这两项和前面签发jwt的设置一致
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["SSOSetting:clientSecret"]))//client secret
        };
    });

#region 非对称加密-鉴权
//var rsa = RSA.Create();
//byte[] publickey = Convert.FromBase64String(AppSetting.PublicKey); //公钥,去掉begin...  end ...
////rsa.ImportPkcs8PublicKey 是一个扩展方法,来源于RSAExtensions包
//rsa.ImportPkcs8PublicKey(publickey);
//var key = new RsaSecurityKey(rsa);
//var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaPKCS1);

//builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
//    .AddJwtBearer(options =>
//    {
//        options.TokenValidationParameters = new TokenValidationParameters
//        {
//            //Audience,Issuer,clientSecret的值要和sso的一致

//            //JWT有一些默认的属性,就是给鉴权时就可以筛选了
//            ValidateIssuer = true,//是否验证Issuer
//            ValidateAudience = true,//是否验证Audience
//            ValidateLifetime = true,//是否验证失效时间
//            ValidateIssuerSigningKey = true,//是否验证client secret
//            ValidIssuer = builder.Configuration["SSOSetting:issuer"],//
//            ValidAudience = builder.Configuration["SSOSetting:audience"],//Issuer,这两项和前面签发jwt的设置一致
//            IssuerSigningKey = signingCredentials.Key
//        };
//    });

#endregion



var app = builder.Build();
ServiceLocator.Instance = app.Services; //用于手动获取DI对象
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthentication();//这个加在UseAuthorization 前
app.UseAuthorization();


app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

然后加接口根据授权code获取token,增加AccountController

 /// <summary>
    /// 用户信息
    /// </summary>

    public class AccountController : Controller
    {
        private IHttpClientFactory _httpClientFactory;
        private readonly Cachelper _cachelper;
        public AccountController(IHttpClientFactory httpClientFactory, Cachelper cachelper)
        {
            _httpClientFactory = httpClientFactory;
            _cachelper = cachelper;
        }

        /// <summary>
        /// 获取用户信息,接口需要进行权限校验
        /// </summary>
        /// <returns></returns>
        [MyAuthorize]
        [HttpPost]
        public ResponseModel<UserDTO> GetUserInfo()
        {
            ResponseModel<UserDTO> user = new ResponseModel<UserDTO>();
            return user;
        }
        /// <summary>
        /// 登录成功回调
        /// </summary>
        /// <returns></returns>
        public ActionResult LoginRedirect()
        {
            return View();
        }
        //根据authCode获取token
        [HttpPost]
        public async Task<ResponseModel<GetTokenDTO>> GetAccessCode([FromBody] GetAccessCodeRequest request)
        {
            ResponseModel<GetTokenDTO> result = new ResponseModel<GetTokenDTO>();
            //请求SSO获取 token
            var client = _httpClientFactory.CreateClient();
            var param = new { authCode = request.authCode };
            string jsonData = System.Text.Json.JsonSerializer.Serialize(param);
            StringContent paramContent = new StringContent(jsonData);

            //请求sso获取token
            var response = await client.PostAsync("https://localhost:7000/SSO/GetToken", new StringContent(jsonData, Encoding.UTF8, "application/json"));
            string resultStr = await response.Content.ReadAsStringAsync();
            result = System.Text.Json.JsonSerializer.Deserialize<ResponseModel<GetTokenDTO>>(resultStr);
            if (result.code == 0) //成功
            {
                //成功,缓存token到局部会话
                string token = result.data.token;
                string key = $"SessionCode:{request.sessionCode}";
                string tokenKey = $"token:{token}";
                _cachelper.StringSet<string>(key, token, TimeSpan.FromSeconds(result.data.expires));
                _cachelper.StringSet<bool>(tokenKey, true, TimeSpan.FromSeconds(result.data.expires));
                Console.WriteLine($"获取token成功,局部会话code:{request.sessionCode},{Environment.NewLine}token:{token}");
            }

            return result;
        }
        /// <summary>
        /// 退出登录
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [HttpPost]
        public  ResponseModel LogOut([FromBody] LogOutRequest request)
        {
            string key = $"SessionCode:{request.SessionCode}";
            //根据会话取出token
            string token = _cachelper.StringGet<string>(key);
            if (!string.IsNullOrEmpty(token))
            {
                //清除token
                string tokenKey = $"token:{token}";
                _cachelper.DeleteKey(tokenKey);
            }
            Console.WriteLine($"会话Code:{request.SessionCode}退出登录");
            return new ResponseModel().SetSuccess();
        }
    }

还有得到的token还没过期,如果我退出登录了,怎么判断这个会话token失效了呢?

这里需要拦截认证过滤器,判断token在缓存中被删除,则认证不通过,增加文件MyAuthorize

  /// <summary>
    /// 拦截认证过滤器
    /// </summary>
    public class MyAuthorize : Attribute, IAuthorizationFilter
    {
        private static Cachelper _cachelper = ServiceLocator.Instance.GetService<Cachelper>();

        public void OnAuthorization(AuthorizationFilterContext context)
        {
            string id = context.HttpContext.User.FindFirst("id")?.Value;
            if(string.IsNullOrEmpty(id))
            {
                //token检验失败
                context.Result = new StatusCodeResult(401); //返回鉴权失败
                return;
            }

            Console.WriteLine("我是Authorization过滤器");
            //请求的地址
            var url = context.HttpContext.Request.Path.Value;
            //获取打印头部信息
            var heads = context.HttpContext.Request.Headers;

            //取到token "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoi5byg5LiJIiwiQWNjb3VudCI6ImFkbWluIiwiSWQiOiIxMDEiLCJNb2JpbGUiOiIxMzgwMDEzODAwMCIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlN1cGVyQWRtaW4iLCJleHAiOjE2NTMwNjA0MDIsImlzcyI6IlNTT0NlbnRlciIsImF1ZCI6IndlYjIifQ.aAi5a0zr_nLQQaSxSBqEhHZQ6ALFD_rWn2tnLt38DeA"
            string token = heads["Authorization"];
            token = token.Replace("Bearer", "").TrimStart();//去掉 "Bearer "才是真正的token
            if (string.IsNullOrEmpty(token))
            {
                Console.WriteLine("校验不通过");
                return;
            }
            //redis校验这个token的有效性,确定来源是sso和确定会话没过期
            string tokenKey = $"token:{token}";
            bool isVaid = _cachelper.StringGet<bool>(tokenKey);
            //token无效
            if (isVaid == false)
            {
                Console.WriteLine($"token无效,token:{token}");
                context.Result = new StatusCodeResult(401); //返回鉴权失败
            }
        }
    }

然后需要认证的控制器或方法头部加上[MyAuthorize]即能自动认证。

web1需要登录的页面

@{
    ViewData["Title"] = "Home Page";
}

<div class="text-center">
       <h1 class="display-4">欢迎来到Web1</h1>
    <p>Learn about <a href="https://web2.com:7002">跳转到Web2</a>.</p>
        <p>Learn about <a onclick="logOut()" href="javascript:void(0);">退出登录</a>.</p>
</div>
@section Scripts{
    <script src="~/js/Common.js"></script>
<script>
                    getUserInfo()
            //获取用户信息
            function getUserInfo(){
                //1.cookie是否有 token
                const token=getCookie('token')
                console.log('gettoken',token)
                if(!token)
                {
                    redirectLogin()
                }
                $.ajax({
          type: 'POST',
          url: '/Account/GetUserInfo',
          headers:{"Authorization":'Bearer ' + token},
          success: success,
          error:error
        });
            }
            function success(){
                console.log('成功')
            }
            function error(xhr, exception){
                if(xhr.status===401) //鉴权失败
                {
                    console.log('未鉴权')
                    redirectLogin()
                }
            }
                      //重定向到登录
            function redirectLogin(){
                     window.location.href="https://sso.com:7000/SSO/Login?clientId=web1&redirectUrl=https://web1.com:7001/Account/LoginRedirect"
            }
            //退出登录
            function logOut(){
                clearCookie("token") //清除cookie token
                 clearCookie("refreshToken") //清除cookie refreshToken
                  clearCookie("sessionCode")  //清除cookie 会话

                  //跳转到SSO退出登录
                    window.location.href="https://sso.com:7000/SSO/LogOut?clientId=web1&redirectUrl=https://web1.com:7001/Account/LoginRedirect"
               
            }

</script>
}

sso登录完要跳转回web1的页面

@*
    For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
*@
@{
    Layout = null;
}
   <script src="~/lib/jquery/dist/jquery.min.js"></script>
      <script src="~/js/Common.js"></script>
   <script>
       GetAccessToken();
       //根据code获取token
       function GetAccessToken(){
   
          var params=GetParam()
                  //code
          var authCode=params["authCode"]
          var sessionCode=params["sessionCode"]
          console.log('authcode',authCode)
          var params={authCode,sessionCode}     
$.ajax({
  url:'/Account/GetAccessCode',
  type:"POST",
  data:JSON.stringify(params),
  contentType:"application/json; charset=utf-8",
  dataType:"json",
  success: function(data){
     console.log('token',data)
     if(data.code===0) //成功
     { 
         console.log('设置cookie')
         //把token存到 cookie,过期时间为token有效时间少一分钟
         setCookie("token",data.data.token,data.data.expires-60,"/")
         //刷新token,有效期1天
         setCookie("refreshToken",data.data.refreshToken,24*60*60,"/")
         setCookie("SessionCode",sessionCode,24*60*60,"/")
         //跳转到主页
          window.location.href="/Home/Index"
     }
  }})

       }
           
   </script>

到这里web1的核心代码就完成了,web2的代码跟web1除了配置里面的加密key,其他全部一样,就不再贴出代码了,后面源码有。

到这里,就实现了一处登录,全部登录了。

2、一处退出,全部退出实现

一处退出,处处退出的流程像实现目标中的流程图,web1系统退出,跳转到SSO,让SSO发http请求退出其他的系统,跳转回登录页。

退出有个核心的问题就是,SSO只能让全部系统在当前浏览器上退出,比喻用户A在电脑1的浏览器登录了,在电脑2的浏览器也登录了,在电脑1上退出只能退出电脑1浏览器的登录,

电脑2的登录不受影响,web1退出了,SSO中的http请求退出web2的时候是不经过浏览器请求的,web2怎么知道清除那个token呢?

这里需要在SSO登录的时候生成了一个全局会话,SSO的cookie这时可以生成一个全局code,每个系统登录的时候带过去作为token的缓存key,这样就能保证全部系统的局部会话缓存key是同一个了,

退出登录的时候只需要删除这个缓存key的token即可。

 SSO的登录页面Login.cshtml

@*
    For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
*@
@{
}
<form id="form">
    <div>用户名:<input type="text" id=userName name="userName" /></div>
    <div>密码:<input type="password" id="password" name="password" /></div>
    <div><input type="button" value="提交" onclick="login()" /></div>
</form>

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/js/Common.js"></script>
<script>
    sessionCheck();
    //会话检查
    function sessionCheck(){
          //获取参数集合
            const urlParams=GetParam();
            const clientId=urlParams['clientId'];
            const redirectUrl=urlParams['redirectUrl']
            const sessionCode=getCookie("SessionCode")
            if(!sessionCode)
            {
                return;
            }
            //根据授权码获取code
            var params={clientId,sessionCode}
            $.ajax({
            url:'/SSO/GetCodeBySessionCode',
            data:JSON.stringify(params),
            method:'post',
            dataType:'json',
            contentType:'application/json',
            success:function(data){
                if(data.code===0)
                {
                     const code=data.data
                      window.location.href=redirectUrl+'?authCode='+code+"&sessionCode="+sessionCode
                }
            }
            })
    }

        function login(){
            //获取参数集合
            const urlParams=GetParam();

            const clientId=urlParams['clientId'];
            const redirectUrl=urlParams['redirectUrl']
                const userName=$("#userName").val()
                const password=$("#password").val()
                const params={clientId,userName,password}
            $.ajax({
                    url:'/SSO/GetCode',
                    data:JSON.stringify(params),
                    method:'post',
                    dataType:'json',
                    contentType:"application/json",
                    success:function(data){
                        //获得code,跳转回客户页面
                        if(data.code===0)
                        {    
                        const code=data.data

                       //存储会话,这里的时间最好减去几分钟,不然那边的token过期,这里刚好多了几秒没过期又重新登录了
                        setCookie("SessionCode",code,24*60*60,"/")
                      window.location.href=redirectUrl+'?authCode='+code+'&sessionCode='+code
                        }
                
                    }
                })
            }
            
       
</script>

这里的SessionCode是关键,作为一个全局code,系统登录会同步到个系统,用于统一退出登录时用

SSO的退出登录页面LogOut.cshtml

@*
    For more information on enabling MVC for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
*@
@{
}
<p>退出登录中...</p>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/js/Common.js?v=1"></script>
<script>
      logOut()
      function logOut()
      {
          var sessionCode=getCookie("SessionCode")
      //清除会话
        clearCookie("SessionCode")
        //获取参数集合
              const urlParams=GetParam();
        //跳转到登录
          const clientId=urlParams['clientId'];
              const redirectUrl=urlParams['redirectUrl']

              var params={sessionCode}
              //退出登录
              $.ajax({
    url:'/SSO/LogOutApp',
    type:"POST",
    data:JSON.stringify(params),
    contentType:"application/json; charset=utf-8",
    dataType:"json",
    success: function(data){
       console.log('token',data)
       if(data.code===0) //成功
       {
           //跳转到登录页面
            window.location.href='/SSO/Login'+'?clientId='+clientId+'&redirectUrl='+redirectUrl
       }
    }})


      }

</script>

退出登录接口:

     /// <summary>
        /// 退出登录
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [HttpPost]
        public async Task<ResponseModel> LogOutApp([FromBody] LogOutRequest request)
        {
            //删除全局会话
            string sessionKey = $"SessionCode:{request.sessionCode}";
            _cachelper.DeleteKey(sessionKey);
            var client = _httpClientFactory.CreateClient();
            var param = new { sessionCode = request.sessionCode };
            string jsonData = System.Text.Json.JsonSerializer.Serialize(param);
            StringContent paramContent = new StringContent(jsonData);

            //这里实战中是用数据库或缓存取
            List<string> urls = new List<string>()
            {
                "https://localhost:7001/Account/LogOut",
                "https://localhost:7002/Account/LogOut"
            };
            //这里可以异步mq处理,不阻塞返回
            foreach (var url in urls)
            {
                //web1退出登录
                var logOutResponse = await client.PostAsync(url, new StringContent(jsonData, Encoding.UTF8, "application/json"));
                string resultStr = await logOutResponse.Content.ReadAsStringAsync();
                ResponseModel response = System.Text.Json.JsonSerializer.Deserialize<ResponseModel>(resultStr);
                if (response.code == 0) //成功
                {
                    Console.WriteLine($"url:{url},会话Id:{request.sessionCode},退出登录成功");
                }
                else
                {
                    Console.WriteLine($"url:{url},会话Id:{request.sessionCode},退出登录失败");
                }
            };
            return new ResponseModel().SetSuccess();

        }

web1,web2的退出登录接口

     /// <summary>
        /// 退出登录
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [HttpPost]
        public  ResponseModel LogOut([FromBody] LogOutRequest request)
        {
            string key = $"SessionCode:{request.SessionCode}";
            //根据会话取出token
            string token = _cachelper.StringGet<string>(key);
            if (!string.IsNullOrEmpty(token))
            {
                //清除token
                string tokenKey = $"token:{token}";
                _cachelper.DeleteKey(tokenKey);
            }
            Console.WriteLine($"会话Code:{request.SessionCode}退出登录");
            return new ResponseModel().SetSuccess();
        }

到这里,一处退出,全部退出也完成了。

3、双token机制实现

token和refresh_token生成算法一样就可以了,知识token的有效期端,refresh_token的有效期长。

那刷新token时怎么知道这个是刷新token呢,SSO生成刷新token的时候,把它保存到缓存中,刷新token的时候判断缓存中有就是刷新token。

生成双token的代码:

     /// <summary>
        /// 根据授权码,获取Token
        /// </summary>
        /// <param name="userInfo"></param>
        /// <param name="appHSSetting"></param>
        /// <returns></returns>
        public ResponseModel<GetTokenDTO> GetTokenWithRefresh(string authCode)
        {
            ResponseModel<GetTokenDTO> result = new ResponseModel<GetTokenDTO>();

            string key = $"AuthCode:{authCode}";
            string clientIdCachekey = $"AuthCodeClientId:{authCode}";
            string AuthCodeSessionTimeKey = $"AuthCodeSessionTime:{authCode}";

            //根据授权码获取用户信息
            CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>(key);
            if (currentUserModel == null)
            {
                throw new Exception("code无效");
            }
            //清除authCode,只能用一次
            _cachelper.DeleteKey(key);

            //获取应用配置
            string clientId = _cachelper.StringGet<string>(clientIdCachekey);
            //刷新token过期时间
            DateTime sessionExpiryTime = _cachelper.StringGet<DateTime>(AuthCodeSessionTimeKey);
            DateTime tokenExpiryTime = DateTime.Now.AddMinutes(10);//token过期时间10分钟
             //如果刷新token有过期期比token默认时间短,把token过期时间设成和刷新token一样
            if (sessionExpiryTime > DateTime.Now && sessionExpiryTime < tokenExpiryTime)
            {
                tokenExpiryTime = sessionExpiryTime;
            }
            //获取访问token
            string token = this.IssueToken(currentUserModel, clientId, (sessionExpiryTime - DateTime.Now).TotalSeconds);


            TimeSpan refreshTokenExpiry;
            if (sessionExpiryTime != default(DateTime))
            {
                refreshTokenExpiry = sessionExpiryTime - DateTime.Now;
            }
            else
            {
                refreshTokenExpiry = TimeSpan.FromSeconds(60 * 60 * 24);//默认24小时
            }
            //获取刷新token
            string refreshToken = this.IssueToken(currentUserModel, clientId, refreshTokenExpiry.TotalSeconds);
            //缓存刷新token
            _cachelper.StringSet(refreshToken, currentUserModel, refreshTokenExpiry);
            result.SetSuccess(new GetTokenDTO() { token = token, refreshToken = refreshToken, expires = 60 * 10 });
            Console.WriteLine($"client_id:{clientId}获取token,有效期:{sessionExpiryTime.ToString("yyyy-MM-dd HH:mm:ss")},token:{token}");
            return result;
        }

根据刷新token获取token代码:

     /// <summary>
        /// 根据刷新Token获取Token
        /// </summary>
        /// <param name="refreshToken"></param>
        /// <param name="clientId"></param>
        /// <returns></returns>
        public string GetTokenByRefresh(string refreshToken, string clientId)
        {
            //刷新Token是否在缓存
            CurrentUserModel currentUserModel = _cachelper.StringGet<CurrentUserModel>($"RefreshToken:{refreshToken}");
            if(currentUserModel==null)
            {
                return String.Empty;
            }
            //刷新token过期时间
            DateTime refreshTokenExpiry = _cachelper.StringGet<DateTime>($"RefreshTokenExpiry:{refreshToken}");
            //token默认时间为600s
            double tokenExpiry = 600;
            //如果刷新token的过期时间不到600s了,token过期时间为刷新token的过期时间
            if(refreshTokenExpiry>DateTime.Now&&refreshTokenExpiry<DateTime.Now.AddSeconds(600))
            {
                tokenExpiry = (refreshTokenExpiry - DateTime.Now).TotalSeconds;
            }

                //从新生成Token
                string token = IssueToken(currentUserModel, clientId, tokenExpiry);
                return token;

        }

四、效果演示

这里项目的SSO地址是:https://localhost:7000 ,web1地址是:https://localhost:7001,web2地址是:https://localhost:7002

修改hosts文件,让他们在不同域名下,cookie不能共享。

win10路径:C:\Windows\System32\drivers\etc\hosts 在最后加入

127.0.0.1 sso.com
127.0.0.1 web1.com
127.0.0.1 web2.com

这样得到新的地址,SSO地址:https://sso.com:7000 ,web1地址是:https://web1.com,web2地址是:https://web2.com

 

630011-20220529235911715-1346968904.gif

1、 这里一开始,访问https://web2.com没登录跳转到https://sso.com。

2、然后访问https://web1.com也没登录,也跳转到了https://sso.com,证明web1,web2都没登录。

3、然后在跳转的sso登录后跳转回web1,然后点https://web2.com的连接跳转到https://web2.com,自动登录了。

4、然后在web1退出登录,web2刷新页面,也退出了登录。

再看一下这些操作下SSO日志打印的记录。

630011-20220530000640115-965384197.png

 到这里.NET6下基于JWT+OAuth2.0的SSO就完成了。

 最后附源码:https://github.com/weixiaolong325/SSO.Demo.SSO


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK