隐藏

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

发布:2022/7/13 23:12:59作者:管理员 来源:本站 浏览次数:1033

一、简介

单点登录(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、一处登录,全部登录

流程图为:

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

地址为:sso.com/SSO/Login?,输入用户名,密码,登录成功,生成授权码code,创建一个全局会话(cookie,redis)。

带着授权码跳转回A系统地址:web1.com/Account/LoginR

然后A系统的回调地址用这个AuthCode调用SSO获取token,获取到token,创建一个局部会话(cookie,redis),再跳转到web1.com。这样A系统就完成了登录。

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

SSO有全局会话证明已经登录过,直接用全局会话code获取B系统的授权码code,带着授权码跳转回B系统web2.com/Account/LoginR,然后B系统的回调地址用这个AuthCode调用SSO获取token,获取到token创建一个局部会话(cookie,redis),再跳转到web2.com

整个过程不用输入用户名密码,这些跳转基本是无感的,所以B就自动登录好了。

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

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

2、一处退出,全部退出

流程图为:

A系统退出,把自己的会话删除,然后跳转到SSO的退出登录地址:sso.com/SSO/Logout?

SSO删除全局会话,然后调接口删除获取了token的系统,然后在跳转到登录页面,sso.com/SSO/Login?,这样就实现了一处退出,全部退出了。

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

ar 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地址是:一、简介

单点登录(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、一处登录,全部登录

流程图为:

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

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

2、浏览器访问B系统,发现B系统没登录,跳转到统一登录中心(SSO),带上B系统的回调地址,地址为:sso.com/SSO/Login?,SSO有全局会话证明已经登录过,直接用全局会话code获取B系统的授权码code,带着授权码跳转回B系统web2.com/Account/LoginR,然后B系统的回调地址用这个AuthCode调用SSO获取token,获取到token创建一个局部会话(cookie,redis),再跳转到web2.com。整个过程不用输入用户名密码,这些跳转基本是无感的,所以B就自动登录好了。

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

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

2、一处退出,全部退出

流程图为:

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

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

ar 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地址:sso.com:7000 ,web1地址是:web1.com,web2地址是:web2.com

动图封面

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

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

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

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

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

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

源码Github:github.com/weixiaolong3

转自:包子wxl
链接:cnblogs.com/wei325/p/16 ,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地址:sso.com:7000 ,web1地址是:web1.com,web2地址是:web2.com

动图封面

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

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

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

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

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

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


源码Github:github.com/weixiaolong3

转自:包子wxl
链接:cnblogs.com/wei325/p/16
发布于 2022-06-02 23:50