DA21 –
验证和应用程序安全
(Delphi)
本文描述了在
Data Abstract 4.0
和
'Vinci'
的
Delphi
版本中如何处理登陆和用户验证
..NET
版本见
DA24.
导论
绝大多数系统需要保障一定级别的安全
,
如加密
,
简单的登陆注销
,
当获取数据或将数据更新回数据源时基于更复杂的用户及角色的逻辑应用
.
LoginSample
项目是一个全面讲解如何实现应用程序安全和验证的范例
.
注意
:
我们不在这个范例中讨论加密
,
这些只不过需要设置
RemObjects SDK
消息组件的一些属性而已
.
在客户端
,
用户首先需要提供用户名
,
密码以及数据库连接名称登陆
.
由于服务端可以运行于
MSSQL
的
Northwind
数据库和
Interbase/Firebird
的
Employee.gdb,
所以第三个参数也是必要的
.
登陆成功后
,
用户可以查询
Orders
数据
.
依赖于不同的用户
,
数据通过特定
Customer
筛选
.
同时对于登陆用户的不同
,
管理数据的功能也或开放或禁用
.
我们将检验服务端和客户端项目看看这些是如何实现的
.
服务端的
RODL
文件
如果你打开服务端项目并启动
Service Builder,
你将看到如下
RODL
文件
:
可见
,
服务端开发了两个服务
: LoginService
和
DataService.
LoginService
LoginService
是本例需要编码的第一个服务
,
其开放的接口如下
:
{ ILoginService }
ILoginService = interface(IDARemoteService)
[
'{16A949AC-0759-409D-A81B-5CB67352D4E2}'
]
function GetConnectionNames: String;
function Login(const UserName: String;
const Password: String;
const ConnectionName: String;
out LoginInfo: TDALoginInfo): Boolean;
procedure Logout;
end;
LoginService.GetConnectionNames
GetConnectionNames
返回服务端定义的数据库连接列表
.
方法返回的字符串使用逗号间隔连接名称
,
如
"IBX,ADO"
并加载到客户端项目的适当
ComboBox
中
.
在客户端主窗体的
OnCreate
事件中调用这个方法
:
procedure
TClientForm.FormCreate(Sender: TObject);
var i : integer;
begin
{ Gets the list of the connections defined on the server }
cbConnectionName.Items.CommaText :=
(svcLoginService as ILoginService).GetConnectionNames;
cbConnectionName.ItemIndex :=
1
;
EnforceSecurity;
end;
在
OnCreate
事件中提供附加的链接列表同时也确保客户端应用程序可以连接到服务端
.
如果由于某些原因客户端无法与服务端通讯
,
将显示
"
不能建立与服务端的连接
"
错误
,
客户端应用程序立即退出
.
事件处理的最后一行代码调用
EnforceSecurity
方法
,
简单的设置按钮的可用或禁用状态限制逻辑操作
.
这是
EnforceSecurity
方法的实现
:
procedure
TClientForm.EnforceSecurity;
begin
if not
LoggedIn and dtOrders.Active then dtOrders.Close;
if not LoggedIn
then Caption:=Application.Title+
' - Lot Logged In '
else
Caption:=Application.Title+
' - Session '
+LoginInfo.SessionID;
bbLogout.Enabled := LoggedIn;
bbLogin.Enabled := not LoggedIn;
bbQueryOrders.Enabled:=LoggedIn and HasPrivilege(priv_QueryOrders);
bbAdminUsers.Enabled:=LoggedIn and HasPrivilege(priv_AdminUsers);
end;
这个方法在很多地方调用
,
以简单高效的方式保证用户界面与数据同步
.
接下来我们将分析
LoggedIn
属性和
HasPrivilege
方法
.
LoginService.Login
这个方法可能是本范例最重要的基础方法
.
其需要三个输入参数
(UserName, Password
和
ConnectionName)
并返回两个值
:TDALoginInfo
类型的
LoginInfo
以及一个
Boolean
值
.
LoginInfo
定义在
Data Abstract RODL
文件并声明如下
:
{ TDALoginInfo }
TDALoginInfo = class(TROComplexType)
private
[..]
published
property
SessionID: String read fSessionID write fSessionID;
property UserID: String read fUserID write fUserID;
property Privileges: TDAStringArray
read GetPrivileges write fPrivileges;
property Attributes: TDAStringArray
read GetAttributes write fAttributes;
property Data: Binary read GetData write fData;
end;
Login
方法需要向客户端返回数据用于不需要做频繁的网络往返就可以配置用户界面和执行安全检查
. TDALoginInfo.SessionID
属性返回到客户端并作为区别于其他用户的值保持下来
.
在
RemObjects SDK
的老版本中这个值必须赋给
,
因为其一直都是在服务端初始化的
. RemObjects SDK
最近版本中
,
没有必要在第一次调用时服务端生成新的
Session
值传递给客户端了
.
服务端
,Login
方法代码如下
:
function
TLoginService.Login(const UserName, Password,
ConnectionName: String; out LoginInfo: TDALoginInfo): Boolean;
var
ds : IDADataset;
isadmin : boolean;
position : TDAField;
begin
{ Aquires the connection specified by the parameter ConnectionName.
Notice how the property AquireConnection of this service is set
to FALSE. The reason is that we cannot aquire a connection before
the execution of this method. }
Connection := Schema.ConnectionManager.NewConnection(ConnectionName);
{ Validates the login }
ds := Schema.NewDataset(Connection, ds_ValidateLogin,
[
'UserName'
,
'Password'
],
[UserName, Password]);
result := (ds.RecordCount=
1
);
if not result then begin
{ Invalid login. The temporary session is not to be kept. }
DestroySession;
Exit;
end
else begin
{ Successful login. We send back information to the client }
LoginInfo := TDALoginInfo.Create;
LoginInfo.UserID := UserName;
LoginInfo.SessionID := GUIDToString(Session.SessionID);
position := ds.FieldByName(
'Position'
);
{ In this demo, we assume that only Buchanan Steven can administer
users in the Northwind database or any employee having the "Admin"
job in the Employee database (i.e. Terri Lee). Others can only
query for the orders. }
isadmin := (SameText(position.AsString,
'Admin'
)
or SameText(position.AsString,
'Sales Manager'
));
if isadmin
then LoginInfo.Privileges.Add(priv_AdminUsers);
LoginInfo.Privileges.Add(priv_QueryOrders);
{ We also store the name of the connection for this user and a
value to indicate if he/she is an administrator to enforce
server side security in the other service }
Session.Values[sess_ConnectionName] := ConnectionName;
Session.Values[priv_CanQueryAllOrders] := isadmin;
end;
end
;
特别注意当代码设置返回值为
True
并初始化输出参数
LoginInfo
的部分
.
在这部分中你将看到这样的代码行
:
LoginInfo.SessionID := GUIDToString(Session.SessionID);
Session
属性可用于所有的
RemObjects
服务
,
如果你的服务使用了
Session Manager
将会被初始化
.
如下截图展示了
LoginService
的数据模块属性
:
如果没有设置
SessionManager
属性
,Session
属性将返回
Null.
另外值得注意的事情是我们在登陆处理中只传递了数据库练级名称
.
如果你查看
Login
方法实现代码的最后两行
,
将会看到我们随同其他值一起将
ConnectionName
保存到了用户
Session
中
.
这两个值将在
DataService
中用于决定连接到那个数据库以及执行一些安全操作
.
TDALoginInfo
类型应该作为模板使用
,
当你创建自己的登陆服务时其不是必须的
.
你可以创建更多或更少的属性的自定义类型
.
客户端代码在
bbLogin
按钮中触发调用
Login
方法
,
代码如下
:
procedure
TClientForm.bbLoginClick(Sender: TObject);
var
res : boolean;
begin
FreeAndNIL(fLoginInfo);
res := (svcLoginService as ILoginService).Login(eUserName.Text,
ePassword.Text, cbConnectionName.Text, fLoginInfo);
if not res
then MessageDlg(
'Invalid login!'
, mtWarning, [mbOK],
0
);
{ Enables or disables buttons according to privileges }
EnforceSecurity;
end
;
对
session
和
LoginService.Logout
的其他思考
当客户端调用远程服务的方法时
,
其
ClientID
属性值包含在请求之中
.
这是透明的而且在
BIN
和
SOAP
消息组件中同时支持
.
这允许服务在
SessionManager
中查找预先存在的客户端
Session
或当特定客户端
ID
不存在时创建一个新的
Session.
这样这个新的
Session
将具有一个
SessionID(
匹配到关联
TROMessage.ClientID
的
ClientID).
如果
Sessin
没有发现将临时生成一个保存在内存中
.Session
将保存在
SessionManager
中直到调用了
DestroySession
方法
.
Session
查找在
TRORemoteDataModule
类
(
你服务的父类
)
的
DoOnActivate
方法中完成
.
这个方法及其对应的
DoOnDeactivate
方法将在每次远程方法调用前后自动调用
.
procedure
TRORemoteDataModule.DoOnActivate(
aClientID: TGUID; const aMessage : IROMessage);
begin
if
(csDesigning in ComponentState) then Exit;
fSession := NIL;
if not CustomRequireSession(aMessage) then Exit;
if Assigned(fSessionManager) then begin
fNewSession := FALSE;
fDestroySession := FALSE;
// Resets the flags
fSession := fSessionManager.FindSession(aClientID);
if (fSession=NIL) then begin
if
RequiresSession then
RaiseError(err_SessionNotFound,
[GUIDToString(aClientID)],
EROSessionNotFound)
else begin
fSession := fSessionManager.CreateSession(aClientID);
fNewSession := TRUE;
end;
end;
end
else begin
if
RequiresSession then
RaiseError(
'SessionManager required, but not assigned'
);
end;
end
;
可见
,
服务要求
SessionManager
查找
Session
的代码只有一行
fSession := fSessionManager.FindSession(aClientID);
如果
Session
没有找到
,RequiresSession
设置为
True
时将抛出一个异常
,
否则自动生成一个新的
Session.
RequiresSession
是
RemObjects
服务的非常重要的属性
,
保证除非
Session
以前被创建并保存在
SessionManager
中
(
通常使用如上面的
Login
方法
)
否则不能调用任何服务方法
.
查看本范例中我们编写的两个服务的这个属性值
:
LoginService
的
RequireSession
属性设置为
False
暗示任何客户端随时都可以调用这个服务
.
而
DataService
则不允许调用任何方法除非提前生成
Session
并保存在
SessionManager
中
.
一旦服务方法调用完毕
, TRORemoteDataModule.DoOnDeactivate
被调用
.
这个方法实现如下
:
procedure
TRORemoteDataModule.DoOnDeactivate(aClientID: TGUID);
var
lSessionID: TGUID;
begin
if
(csDesigning in ComponentState) then Exit;
if Assigned(fSessionManager) and (fSession<>NIL) then begin
if
fDestroySession then begin
if
NewSession then begin
fSessionManager.DeleteTemporarySession(fSession)
end
else begin
lSessionID := fSession.SessionID;
fSessionManager.ReleaseSession(fSession, false);
fSessionManager.DeleteSession(lSessionID, false)
end;
end
else begin
fSessionManager.ReleaseSession(fSession, NewSession);
end;
end;
end;
如上面所述
,
这个方法负责在
SessionManager
中存储或删除
Session,
由
fDestroySession
标志位决定执行什么动作
,
其值可以通过调用
DestroySession
方法设置为
False.
如果在
Login
方法中登陆失败将调用
DestroySession:
function
TLoginService.Login(const UserName, Password,
ConnectionName: String; out LoginInfo: TDALoginInfo): Boolean;
[..]
if not result then begin
{ Invalid login. The temporary session is not to be kept. }
DestroySession;
Exit;
end
[..]
或在
Logout
方法中
:
procedure
TLoginService.Logout;
begin
DestroySession;
end
;
总结
:
当远程调用方法并且为服务指定了
SessionManager
时
,
服务都将去试图查找一个已存在的
Session.
如果没有查找到则建立一个临时的
Session.
你可以通过服务的
Session
属性存取这个
Session
并调用
DestroySession
方法删除
.
如果你不希望创建临时
Session
并且要确保
Session
已经提前创建
,
只需要设置
RequiresSession
为
TRUE.
DataService
DataService
非常简单
.
其除了从
IDARemoteService
继承的方法外没有引入任何方法
.
我们实现了这个服务的两个事件处理
:OnBeforeAcquireConnection
和
OnBeforeGetDatasetData.
DataService.OnBeforeAcquireConnection
这个事件在将
AcquireConnection
设置为
Ture
并且当服务向
ConnectionManager
请求数据库连接时触发
.
你可以运行时重新设置
ConnectionName
连接到不同的数据库
,
或在其值为空时使用
ConnectionManager
中的默认连接
.
这里我们要使用在执行
LoginService.Login
方法时保存在
Session
中的连接名称
:
procedure
TDataService.DARemoteServiceBeforeAcquireConnection(
Sender: TDARemoteService; var ConnectionName: String);
begin
{ This value has been stored during the Login method }
ConnectionName := Session.Values[sess_ConnectionName]
end
;
DataService.OnBeforeGetDatasetData
这个事件我们执行一些服务端的安全逻辑
,
如本文开始时提到的
.
使用以
priv_CanQueryAllOrders
为存储
ID
的
Boolean
类型的
Session
值
(
见
Login
方法
),
我们或者获取所有的
Orders
记录或者硬编码对特定客户做筛选
.
代码如下
:
procedure
TDataService.DARemoteServiceBeforeGetDatasetData(
const Dataset: IDADataset; const IncludeSchema: Boolean;
const MaxRecords: Integer);
var
filtervalue : variant;
begin
{ This is an example of how to use session values to enforce
additional server side security. }
if
(Session.Values[priv_CanQueryAllOrders]=FALSE) then begin
if
Supports(Connection, IInterbaseConnection)
then filtervalue :=
'1006'
// IB filter
else
filtervalue :=
'FRANK'
;
// MSSQL filter
Dataset.Where.AddCondition(
'CustomerID'
, cEqual, filtervalue);
end;
end
;
当存取将
RequireSession
设置为
True
的服务时设计时的考虑
最后一个重点考虑的事情是留意在设计时存取设置
RequireSession
为
True
的服务
.
如果你启动你的服务并试图打开客户端窗体中的
dtOrders
数据表
,
你将得到一个
"Session {xyz} not found"
的错误提示
.
虽然在设计时也能得到安全验证是件好事情
,
但是却在开发客户端应用程序时引入了一定的问题
.
我们如何在需要登陆但是必须要启动客户端应用程序才能登陆的情况下存取
DataService
服务呢
?
答案是前期已经拖放的
TDADesignTimeCall
组件
.
这个组件允许你在设计时调用远程方法
,
我们只需要使用这个组件调用
LoginService.Login
并以
Nancy Davolio
的身份登陆
.
双击组件方法将执行
,
这时
Delphi IDE
就像一个登录到服务端的客户端
.
总结
RemObjects SDK
和
Data Abstract
组合提供了很多预先定义好的功能创建安全系统
.
我们希望本文能让你理解
TROMessage.ClientID
和
Session
如何协同运行
,
以及为什么返回像
TDALoginInfo
的结构在减少网络往返次数的情况下实现客户端安全
.