一、前言
实操使用IdentityServer4完成一个单点登录,不是网上千篇一律的按照官方文档弄的demo,在这记录下这个过程中踩的坑以及解决办法。
二、理论
网上有很多IdentityServer4的理论,官网文档也有,这里就不赘述了。
三、认证服务器配置
1、配置证书,ids4需要一个证书,在测试的时候可以使用AddDeveloperSigningCredential 方法,他会自动生成。发布上线最好自己使用openssl生成,步骤如下:
下载地址Win64 OpenSSL v1.1.0k,安装后进行设置,
d:\openssl>set RANDFILE=d:\openssl\.rnd
d:\openssl>set OPENSSL_CONF=d:\openssl\OpenSSL-Win64\bin\openssl.cfg
执行生成命令
openssl req -newkey rsa:2048 -nodes -keyout cas.clientservice.key -x509 -days 365 -out cas.clientservice.cer
相应目录下面会生成cas.clientservice.cer和cas.clientservice.key两个文件
执行合并命令
openssl pkcs12 -export -in cas.clientservice.cer -inkey cas.clientservice.key -out IS4.pfx
IS4.pfx是证书名称,可以自己修改,中途会提示让你输入Export Password,这个password在IS4中会用到,需要记下来。
2、数据迁移
新建一个.net core mvc项目,引入ids4。平时我们练习都是把数据保存在内存中,实际在生产环境要持久化存储,所以我们使用SQL Server数据库。
Nuget安装
Install-Package IdentityServer4.EntityFramework
在Startup.cs的
ConfigureServices 进行配置
string connectionString = AppConfig.GetSection("ConnectionString").Value;
var migrationsAssembly = typeof(AuditLog).GetTypeInfo().Assembly.GetName().Name;
//认证中心
services.AddIdentityServer(opts =>
{
//opts.PublicOrigin = "http://www.baidu.com"; //修改前缀
opts.UserInteraction = new UserInteractionOptions
{
LoginUrl = "/Account/Login",
ErrorUrl = "Home/Error"
};
}).AddSigningCredential(new X509Certificate2("IS4.pfx", "xxxx")).AddConfigurationStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
})
// this adds the operational data from DB (codes, tokens, consents)
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
// this enables automatic token cleanup. this is optional.
options.EnableTokenCleanup = true;
})
//.AddInMemoryIdentityResources(IdentityConfig.GetIdentityResources())
//.AddInMemoryApiResources(IdentityConfig.GetApiResources())
//.AddInMemoryClients(IdentityConfig.GetClients())
.AddResourceOwnerValidator()
.AddProfileService();
添加数据库迁移文件
dotnet ef migrations add InitialIdentityServerPersistedGrantDbMigration -c PersistedGrantDbContext -o Data/Migrations/IdentityServer/PersistedGrantDb
dotnet ef migrations add InitialIdentityServerConfigurationDbMigration -c ConfigurationDbContext -o Data/Migrations/IdentityServer/ConfigurationDb
初始化数据库 该方法执行一次即可。
private void InitializeDatabase(IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.GetService().CreateScope())
{
serviceScope.ServiceProvider.GetRequiredService().Database.Migrate();
var context = serviceScope.ServiceProvider.GetRequiredService();
context.Database.Migrate();
if (!context.Clients.Any())
{
foreach (var client in Config.GetClients())
{
context.Clients.Add(client.ToEntity());
}
context.SaveChanges();
}
if (!context.IdentityResources.Any())
{
foreach (var resource in Config.GetIdentityResources())
{
context.IdentityResources.Add(resource.ToEntity());
}
context.SaveChanges();
}
if (!context.ApiResources.Any())
{
foreach (var resource in Config.GetApis())
{
context.ApiResources.Add(resource.ToEntity());
}
context.SaveChanges();
}
}
}
相应客户端配置
"SSO": {
"Resources": [
{
"Name": "Photo",
"DisplayName": "用户头像",
"Claims": "Photo"
},
{
"Name": "Position",
"DisplayName": "用户职位",
"Claims": "Position"
}
],
"Apis": [
{
"Name": "Ids",
"DisplayName": "管理中心",
"Description": "管理中心",
"Claims": "",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7"
},
{
"Name": "IdsAPI",
"DisplayName": "管理中心接口",
"Description": "管理中心接口",
"Claims": "",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7"
},
{
"Name": "PClient",
"DisplayName": "php客户端",
"Description": "php客户端",
"Claims": "",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7"
},
{
"Name": "PClientAPI",
"DisplayName": "php客户端接口",
"Description": "php客户端接口",
"Claims": "",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7"
},
{
"Name": "NetCoreAPI",
"DisplayName": "php客户端接口",
"Description": "php客户端接口",
"Claims": "",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7"
}
],
"Clients": [
{
"Id": "IdentityServer",
"Name": "管理中心",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7",
"GrantTypes": "hybrid",
"Urls": "http://localhost:5002,https://localhost:5002",
"ApiNames": "Ids,Photo,Position"
},
{
"Id": "IdentityServerAPI",
"Name": "管理中心xxx",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7",
"GrantTypes": "password",
"Urls": "",
"ApiNames": "IdsAPI"
},
{
"Id": "PHPClient",
"Name": "php客户端",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7",
"GrantTypes": "implicit",
"Urls": "http://localhost:8090/callback",
"ApiNames": "PClient,Photo,Position"
},
{
"Id": "PHPAPIClient",
"Name": "php客户端API",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7",
"GrantTypes": "password",
//"AccessTokenLifetime": 180, //过期时间单位秒,默认1800
"Urls": "",
"ApiNames": "PClientAPI,Photo,Position"
},
{
"Id": "NetCoreAPIClient",
"Name": ".NetCore客户端API",
"Secret": "8c31656b4acb74f15bf0d2378d8be3e7",
"GrantTypes": "password",
"AccessTokenLifetime": 36000,
"Urls": "",
"ApiNames": "NetCoreAPI,Photo,Position"
}
]
},
最后说一句,这个针对单个程序,如果有些项目使用类库数据层进行数据操纵,直接在类库中添加迁移文件是会报错的,解决办法是新建一个项目,在新项目中操作完毕后把迁移文件拷贝到相应类库中,不过在认证服务器的Startup中的migrationsAssembly 要相应的改变。
四、.Net客户端
1、MVC客户端,可以引入两种模式:password好hybird,既保护mvc的web跳转,也保护相应API接口,配置如下:
//认证中心
//oidc登陆
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "http://localhost:5000";
options.RequireHttpsMetadata = false;
options.Audience = "IdsAPI";
});
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
//options.DefaultChallengeScheme = "oidc";
options.DefaultChallengeScheme = "Cookies"; //默认跳转自带登陆
}).AddCookie("Cookies", options =>
{
options.LoginPath = new PathString("/Account/Login");
})
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = "http://localhost:5000";
options.RequireHttpsMetadata = false;
options.ClientId = "IdentityServer";
////拒绝登录时 跳转页面
//options.Events.OnRemoteFailure = context =>
//{
// context.Response.Redirect("/Account/Refuse");
// context.HandleResponse();
// return Task.FromResult(0);
//};
options.ClientSecret = "8c31656b4acb74f15bf0d2378d8be3e7";
options.ResponseType = "code id_token";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("Photo");
options.Scope.Add("Position");
options.Scope.Add("Ids");
options.Scope.Add("offline_access");
options.ClaimActions.MapUniqueJsonKey("Id", "Id");
options.ClaimActions.MapUniqueJsonKey("TrueName", "TrueName");
options.ClaimActions.MapUniqueJsonKey("NickName", "NickName");
options.ClaimActions.MapUniqueJsonKey("UserName", "UserName");
options.ClaimActions.MapUniqueJsonKey("Position", "Position");
options.ClaimActions.MapUniqueJsonKey("Photo", "Photo");
});
相应API加入
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] 即可走password认证模式。
五、PHP客户端
1、UI登陆模式,首先在认证服务器配置客户端,认证模式为Implicit,其中urls为php里面配置的回调链接。上面json以展示。
2、新建php项目,这里以laravel框架进行介绍,其他框架大同小异。
composer create-project --prefer-dist laravel/laravel PhpHybirdClient
php artisan key:generate
3、引入必须的包,主要有两个一个是openid,一个是jwt。
composer require jumbojett/openid-connect-php
composer require lcobucci/jwt
4、php客户端进行配置登陆,按照ids4认证服务器中的客户端进行配置
namespace App\Http\Controllers;
use App\User;
use Illuminate\Support\Facades\Auth;
use Jumbojett\OpenIDConnectClient;
use Lcobucci\JWT\Parser;
class AuthController extends Controller
{
public function __construct()
{
$this->middleware('guest');
}
public function Login()
{
$oidc = new OpenIDConnectClient('http://localhost:5000',
'PHPClient', '8c31656b4acb74f15bf0d2378d8be3e7');
$oidc->setResponseTypes(array('id_token'));
$oidc->setRedirectURL('http://localhost:8090/callback'); //设置项目callbackurl
$oidc->addScope(array('openid profile Photo Position'));
$oidc->setAllowImplicitFlow(true);
$oidc->addAuthParam(array('response_mode' => 'form_post'));
$oidc->authenticate();
}
public function Callback()
{
$token = (new Parser())->parse((string)$_POST['id_token']); // Parses from a string
$user = new User();
$user->name = $token->getClaim('TrueName');
$user->id = $token->getClaim('Id');
//这里可以保存登陆用户
$user = User::where('openid',$token->getClaim('Id'))->first();
if (!$user)
$user = new User();
$user->openid = $token->getClaim('Id');
$user->name = $token->getClaim('TrueName');
$user->photo = $token->getClaim('Photo');
$user->position = $token->getClaim('Position');
$user->save();
Auth::login($user);
return redirect('/');
}
}
添加路由
Route::get('/', 'HomeController@Index');
Route::get('login', 'AuthController@Login')->name('login');
Route::post('callback', 'AuthController@Callback')->name('callback');
注意点就是回调链接,是post,laravel有crsf验证,需要在VerifyCsrfToken文件中加一个例外。
protected $except = [
'callback'
];
还需配置用户表以保存用户
class User extends Authenticatable
{
use Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name', 'email', 'password',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password', 'remember_token',
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
protected $dateFormat = 'U';
}
5、php API接口保护
API接口保护,使用中间件,进行接口验证
生成中间件
php artisan make:middleware CheckToken
中间件验证逻辑
$token = (string)$request->id_token;
//本地验证
//$token = (new Parser())->parse((String)$token);
//$signer = new Sha256();
//$publicKey = new Key('file://F:\...\cas.clientservice.cer'); // 公钥证书地址
//if (!$token->verify($signer, $publicKey)) {
// return '验证错误';
//}
//if ($token->getClaim('exp') < time()) {
// return '过期错误';
//}
//if ($token->getClaim('client_id')!='EM') {
// return '客户端ID错误';
//}
//接口验证
$post_data = array(
'client_id' => 'PClientAPI', //是api中的id 不是ids4中的client的id
'client_secret' => '8c31656b4acb74f15bf0d2378d8be3e7',
'token' => $token
);
$postdata = http_build_query($post_data);
$options = array(
'http' => array(
'method' => 'POST',
'header' => 'Content-type:application/x-www-form-urlencoded',
'content' => $postdata,
'timeout' => 15 * 60 // 超时时间(单位:s)
)
);
$context = stream_context_create($options);
$result = file_get_contents('http://localhost:5000/connect/introspect', false, $context);
$request->Cliams = json_decode($result);
if ($result && json_decode($result)->active)
return $next($request);
else
return response()->json(['error' => 'Unauthenticated.'], 401);
分配中间件到指定路由
如果你想要分配中间件到指定路由,首先应该在 app/Http/Kernel.php 文件中分配给该中间件一个 key,默认情况下,该类的 $routeMiddleware 属性包含了 Laravel 自带的中间件,要添加你自己的中间件,只需要将其追加到后面并为其分配一个 key,例如:
protected $routeMiddleware = [
'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'token' => CheckToken::class
];
中间件在 HTTP Kernel 中被定义后,可以使用 middleware 方法将其分配到路由:
Route::get('/', function () {
//
})->middleware('token');
获取在控制器中
public function __construct()
{
$this->middleware('token');
}