This chapter will see the following security-focused features added to the Library Project.
Load the "Chapter 11 (Before) Code" project, either through the New Project templates, or by accessing the project directly from the installation directory. To see the code in its final form, load "Chapter 11 (After) Code" instead.
Because all of the library's data is stored in a SQL Server database, we already use either Windows or SQL Server security to restrict access to the data itself. But once we connect to the database, we will use a custom authentication system to enable and disable features in the application. It's there that we'll put some of the .NET cryptography features into use.
Before adding the interesting code, we need to add some global variables that support security throughout the application. All of the global elements appear in the General.vb file, within the GeneralCode module.
Insert Chapter 11, Snippet Item 1.
Public LoggedInUserID As Integer Public LoggedInUserName As String Public LoggedInGroupID As Integer Public SecurityProfile(MaxLibrarySecurity) As Boolean
Although we added it in a previous step, the LibrarySecurity enumeration is an important part of the security system. Its elements match those found in the Activity table in the Library database. Each enumeration value matches one element in the SecurityProfile array that we just added to the code.
Public Enum LibrarySecurity As Integer ManageAuthors = 1 ManageAuthorTypes = 2 ...more here... ManagePatronGroups = 22 ViewAdminPatronMessages = 23 End Enum Public Const MaxLibrarySecurity As LibrarySecurity = _ LibrarySecurity.ViewAdminPatronMessages
All of the newly added global variables store identity information for the active administrator. When a patron is the active user, the program sets all of these values to their default states. Because this should be done when the program first begins, we'll add an InitializeSystem routine that is called on startup. It also appears in the General module.
Insert Chapter 11, Snippet Item 2.
Public Sub InitializeSystem() ' ----- Initialize global variables here. Dim counter As Integer ' ----- Clear security-related values. LoggedInUserID = -1 LoggedInUserName = "" LoggedInGroupID = -1 For counter = 1 To MaxLibrarySecurity SecurityProfile(counter) = False Next counter End Sub
(The SecurityProfile array has items that range from 0 to MaxLibrarySecurity, but the loop at the end of this code starts from element 1. Because the Activity table starts its counting at 1, I decided to just skip element 0.) The InitializeSystem method is called from the MyApplication_Startup event in the ApplicationEvents.vb file, just before establishing a connection to the database. Let's add that code now.
Insert Chapter 11, Snippet Item 3.
' ----- Perform general initialization. InitializeSystem()
Each time that an administrator tries to use the system, and each time that the administrator logs off and returns the program to patron mode, all of the security-related global variables must be reset. This is done in the ReprocessSecuritySet method, added to the General module.
Insert Chapter 11, Snippet Item 4.
Public Sub ReprocessSecuritySet() ' ----- Reload in the security set for the current ' user. If no user is logged in, clear all settings. Dim counter As Integer Dim sqlText As String Dim dbInfo As SqlClient.SqlDataReader = Nothing ' ----- Clear out the existing items. For counter = 1 To MaxLibrarySecurity SecurityProfile(counter) = False Next counter ' ----- Exit if there is no user logged in. If (LoggedInUserID = -1) Or _ (LoggedInGroupID = -1) Then Return Try ' ----- Load in the security elements for this user. sqlText = "SELECT ActivityID FROM GroupActivity " & _ "WHERE GroupID = " & LoggedInGroupID dbInfo = CreateReader(sqlText) Do While (dbInfo.Read) SecurityProfile(CInt(dbInfo!ActivityID)) = True Loop dbInfo.Close() Catch ex As Exception ' ----- Some database-related error. GeneralError("ReprocessSecuritySet", ex) If (dbInfo IsNot Nothing) Then dbInfo.Close() ' ----- Un-login the administrator through recursion. LoggedInUserID = -1 LoggedInGroupID = -1 ReprocessSecuritySet() Finally dbInfo = Nothing End Try End Sub
This routine uses code built in Chapter 10, "ADO.NET," and other earlier chapters. When it detects an authorized user (the LoggedInUserID variable), it creates a SqlDataReader object with that user's allowed security features, and stores those settings in the SecurityProfile array. Once loaded, any array element that is True represents an application feature that the administrator is authorized to use. I'll discuss the GroupActivity table a little later in this project section.
If a database error occurs during processing, the code resets everything to patron mode, making a recursive call to ReprocessSecuritySet to clear the SecurityProfile array. (Recursion occurs when a routine directly or indirectly calls itself.)
The entire content of this chapter has been building to this very moment, the section where I reveal the winner of the next presidential election. Wait! Even better than that, I will use one of the .NET hashing methods to encrypt an administrator-supplied password before storing it in the database. One of the tables in the Library database, the UserName table, stores the basic security profile for each librarian or other administrative user, including a password. Because anyone who can get into the database will be able to see the passwords stored in this table, we will encrypt them to make them a little less tempting. (For patrons simply using the program, there shouldn't be any direct access to the database apart from the application, but you never know about those frisky patrons.)
To keep things secure, we'll scramble the user-entered password, using it to generate a hash value, and store the hash value in the database's password field for the user. Later, when an administrative user wants to gain access to enhanced features, the program will again convert the entered password into a hash value, and compare that value to the on-record hashed password.
Each .NET hashing function depends on a secret code. Because the Library Project will only perform a unidirectional encryption, and it will never ask any other program to re-encrypt the password, we'll just use the user's login name as the "secret" key. I decided to use the HMACSHA1 hashing class, mostly for its ability to accept a variable-size key. Although it is reported to have security issues, that shouldn't be a problem for the way that we're using it. I mean, if someone actually got into the database trying to decrypt the passwords stored in the UserName table, they would already have full access to everything in the Library system.
Of course, the encryption code requires references to the System.Security.Cryptography namespace. We'll also need a reference to System.Text for some of the support code. Add the relevant Import statements to the top of the General.vb code file.
Insert Chapter 11, Snippet Item 5.
Imports System.Text Imports System.Security.Cryptography
The actual jumbling of the password occurs in the EncryptPassword routine, making its entrance in the General module.
Insert Chapter 11, Snippet Item 6.
Public Function EncryptPassword(ByVal loginID As String, _ ByVal passwordText As String) As String ' ----- Given a user name and a password, encrypt the ' password so that it is not easy to decrypt. There ' is no limit on password length since it is going ' to be hashed anyway. Dim hashingFunction As HMACSHA1 Dim secretKey() As Byte Dim hashValue() As Byte Dim counter As Integer Dim result As String = "" ' ----- Prepare the secret key. Force it to uppercase for ' consistency, and then stuff it in a byte array. secretKey = (New UnicodeEncoding).GetBytes(UCase(loginID)) ' ----- Create the hashing component using Managed SHA-1. hashingFunction = New HMACSHA1(secretKey, True) ' ----- Calculate the hash value. One simple line of code. hashValue = hashingFunction.ComputeHash( _ (New UnicodeEncoding).GetBytes(passwordText)) ' ----- The hash value is ready, but I like things in ' plaintext when possible. Let's convert it to a ' long hex string. For counter = 0 To hashValue.Length - 1 result &= Hex(hashValue(counter)) Next counter ' ----- Stored passwords are limited to 20 characters. Return Left(result, 20) End Function
The primary methods of interacting with the security providers in .NET are via a byte array or a stream. I opted to use the byte array method, converting the incoming string values through the UnicodeEncoding object's GetBytes method. Once stored as a byte array, I pass the login ID and password as arguments to the HMACSHA1 class' features.
Although I could store the output of the ComputeHash method directly in a database field, I decided to convert the result into readable ASCII characters so that things wouldn't look all weird when I issued SQL statements against the UserName table. My conversion is basic: Convert each byte into its printable hexadecimal equivalent using Visual Basic's Hex function. Then just string the results together. The UserName.Password field only holds 20 characters, so I chop off anything longer.
Just to make sure that this algorithm generates reasonable output, I called EncryptPassword with a few different inputs.
MsgBox("Alice/none: " & _ EncryptPassword("Alice", "") & vbCrLf & _ "Alice/password: " & _ EncryptPassword("Alice", "password") & vbCrLf & _ "Bob/none: " & _ EncryptPassword("Bob", "") & vbCrLf & _ "Bob/password: " & _ EncryptPassword("Bob", "password"))
This code generated the following message.
Alice/none: 6570FC214A797C023F40 Alice/password: 4AEC6C914C65D88BD082 Bob/none: 7F544120E3AB9FB48C32 Bob/password: 274A56F047293EA0B97E
Undoing Some Previous Changes
The UserName, GroupName, and GroupActivity tables in the database define the security profiles for each administrative user. Every user (a record in UserName) is part of one security group (a GroupName record). Each group includes access to zero or more enhanced application features; the GroupActivity table identifies which features match up to each security group record.
To manage these tables, we need to add property forms that edit the fields of a single database record. We already wrote some of the code awhile back. Chapter 8, "Classes and Inheritance," defined the BaseCodeForm.vb file, a template for forms that edit single database records. That same chapter introduced the ListEditRecords.vb file, the "parent" form that displays a listing of already-defined database records. Our record editor for both users and security groups will use the features in these two existing forms.
Your friendly author, Tim Patrick, is about to rant on and on about something that really bugs him. Why not join him in this rant?
When we designed the code for BaseCodeForm.vb in Chapter 8, my goal was to show you the MustInherit and MustOverride class features included with Visual Basic. They are pretty useful features. Unfortunately, they just don't mix well with user interface elements, and here's why: Visual Studio actually creates instances of your forms at design time so that you can interact with them inside the editor. If you were to delve into the source code for, say, a TextBox control, you would find special code that deals with design-time presentation of the control. Interesting? Yes. Flexible? Yes. Perfect in all cases? No.
The problemand problem is putting it mildlyis that Visual Studio won't (actually, can't) create an instance of a class defined as MustInherit. That's because you must inherit it through another class first before you create instances. What does this mean to you? It means that if you try to design a form that inherits from a MustInherit form template, Visual Studio will not present the user interface portion of the form for your editing enjoyment. You can still access the source code of the form, and if this is how you want to design the inherited form, that's fine. But you and I are looking for simplicity in programming, and we plunked down good money for Visual Studio, so we're certainly going to use its visual tools to edit our visual forms.
The upshot of all this rantingand I'm almost at the end of my rant, but you can keep going on if you wantis that we must change the BaseCodeForm.vb file, removing the MustInherit and MustOverride keywords, and making other appropriate adjustments. I've already made the changes to both the before and after templates of the Chapter 11, "Security," code.
This is part of the reality of programming in a complex system like Visual Studio. Sometimes, even after you have done all of your research and carefully mapped out the application features and structure, you run in to some designer- or compiler-specific behavior that forces you to make some change. Once you learn to avoid the major issues, you find that it doesn't happen too often. But when it does occur, it can be a great time to rant.
Managing Security Groups
So, back to our GroupName record editor. I haven't added it to the project yet, so let's add it now. Because it will inherit from another form in the project, we have to allow Visual Studio to instantiate the base form by first compiling the application. This is easily done through the Build Build Library menu command.
To create the new form, select the Project Add Windows Form menu command. When the Add New Item window appears, select "Inherited Form" from the Templates list. Enter "GroupName.vb" in the Name field, and then click the Add button. When the Inheritance Picker form appears (see Figure 11-1), select "BaseCodeForm" from the list, and click OK. The new GroupName form appears, but it looks remarkably like the BaseCodeForm form.
Figure 11-1. Who says you can't pick your own relatives?
Add two Label controls, two TextBox controls, two Button controls, and a CheckedListBox control from the Toolbox, and set their properties using the following settings.
Don't forget to adjust the tab order of the controls on the form.
Let's add the code all at once. Add the next code snippet to the class source code body.
Insert Chapter 11, Snippet Item 7.
Hey, that's nearly 300 lines of source code. Good typing. The class includes two private members. ActiveID holds the ID number of the currently displayed GroupName database record, or 1 when editing new records. The StartingOver flag is a little more interesting. Remember that we are using a shared summary form to display all of the already-entered GroupName records. To allow this generic form, ListEditRecords.vb, to work with the different record editors, we pass an instance of the detail form (GroupName.vb in this case) to the summary form.
Within the ListEditRecords form's code, the instance of GroupName is used over and over, each time the user wants to add or edit a GroupName database record. If the user edits one record, and then tries to edit another, the leftovers from the first record will still be in the detail form's fields. Therefore, we will have to clear them each time we add or edit a different record. The StartingOver flag helps with that process by resetting the focus to the first detail form field in the form's Activated event.
Private Sub GroupName_Activated(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles Me.Activated ' ----- Return the focus to the main field. If (StartingOver) Then RecordFullName.Focus() StartingOver = False End Sub
The related PrepareFormFields private method does the actual clearing and storing of data with each new Add or Edit call. For new records, it simply clears all entered data on the form. When editing an existing record, it retrieves the relevant data from the database, and stores saved values in the various on-form fields. The following statements display the stored group name in the RecordFullName field, a TextBox control.
' ----- Load in the values stored in the database. sqlText = "SELECT FullName FROM GroupName WHERE ID = " & _ ActiveID RecordFullName.Text = CStr(ExecuteSQLReturn(sqlText))
Most of the routines in the GroupName form provide simple overrides for base members of the BaseCodeForm class. The CanUserAdd method, which simply returns False in the base class, includes actual logic in the inherited class. It uses the SecurityProfile array we added earlier to determine if the current user is allowed to add group records.
Public Overrides Function CanUserAdd() As Boolean ' ----- Check the user for security access: add. Return SecurityProfile(LibrarySecurity.ManageGroups) End Function
If you look through the added code, you'll find overrides for all of the BaseCodeForm members except for the UsesSearch and SearchForRecord methods. The derived class accepts the default action for these two members.
The user adds, edits, and deletes group name records through the AddRecord, EditRecord, and DeleteRecord overrides, respectively, each called by code in the ListEditRecords form. Here's the code for EditRecord.
Public Overrides Function EditRecord( _ ByVal recordID As Integer) As Integer ' ----- Edit an existing record. ActiveID = recordID PrepareFormFields() Me.ShowDialog() If (Me.DialogResult = Windows.Forms.DialogResult.OK) Then _ Return ActiveID Else Return -1 End Function
After storing the ID of the record to edit in the ActiveID private field, the code loads the data through the PrepareFormFields method, and prompts the user to edit the record with the Me.ShowDialog call. The form sticks around until some code or control sets the form's DialogResult property. This is done in the ActOK_Click event, and also through the ActCancel button's DialogResult property, which Visual Basic will assign to the form automatically when the user clicks the ActCancel button.
The AddRecord routine is just like EditRecord, but it assigns 1 to the ActiveID member to flag a new record. The DeleteRecord routine is more involved, and uses some of the database code we wrote in the last chapter.
Public Overrides Function DeleteRecord( _ ByVal recordID As Integer) As Boolean ' ----- The user wants to delete the record. Dim sqlText As String On Error GoTo ErrorHandler ' ----- Confirm with the user. If (MsgBox("Do you really wish to delete the " & _ "security group?", MsgBoxStyle.YesNo Or _ MsgBoxStyle.Question, ProgramTitle) <> _ MsgBoxResult.Yes) Then Return False ' ----- Make sure this record is not in use. sqlText = "SELECT COUNT(*) FROM UserName " & _ "WHERE GroupID = " & recordID If (CInt(ExecuteSQLReturn(sqlText)) > 0) Then MsgBox("You cannot delete this record because " & _ "it is in use.", MsgBoxStyle.OkOnly Or _ MsgBoxStyle.Exclamation, ProgramTitle) Return False End If ' ----- Delete the record. TransactionBegin() sqlText = "DELETE FROM GroupActivity " & _ "WHERE GroupID = " & recordID ExecuteSQL(sqlText) sqlText = "DELETE FROM GroupName WHERE ID = " & recordID ExecuteSQL(sqlText) TransactionCommit() Return True ErrorHandler: GeneralError("GroupName.DeleteRecord", Err.GetException()) TransactionRollback() Return False End Function
After confirming the delete with the user, a quick check is done to see if the group is still being used somewhere in the UserName table. If everything checks out fine, the record is deleted using a SQL DELETE statement. Because we need to delete data in two tables, I wrapped it all up in a transaction. If an error does occur, the error handler at the end of the routine will roll back the transaction through TransactionRollback.
Database Integrity Warning
If you have a background in database development, you have already seen the flaw in the delete code. Although I take the time to verify that the record is not in use before deleting it, it's possible that some other user will use it between the time I check the record's use and the time when I actually delete it. Based on the code and database configuration I've presented so far, it would indeed be an issue. When I designed this system, I expected that a single librarian would manage administrative tasks such as this, so I didn't worry about such conflicts and "race conditions."
If you are concerned about the potential for deleting in-use records through code like this, you can enable referential integrity on the relationships in the database. I established a relationship between the GroupName.ID and UserName.GroupID fields, but it's for informational purposes only. You can reconfigure this relationship to have SQL Server enforce the relationship between the tables. If you do this, then it will not be possible to delete an in-use record; an error will occur in the program when you attempt it. That sounds good, and it is, but an overuse of referential integrity can slow down your data access. I will leave this configuration choice up to you.
When the user is done making changes to the record, a click on the OK button pushes the data back out to the database. The ActOK_Click event handler verifies the data, and then saves it.
If (ValidateFormData() = False) Then Return If (SaveFormData() = False) Then Return Me.DialogResult = Windows.Forms.DialogResult.OK
The ValidateFormData method does some simple checks for valid data, such as requiring that the user enter the security group name, and that it is unique. If everything looks good, the SaveFormData routine builds SQL statements that save the data.
Private Function SaveFormData() As Boolean ' ----- The user wants to save changes. ' Return True on success. Dim sqlText As String Dim newID As Integer = -1 On Error GoTo ErrorHandler ' ----- Prepare to save the data. Me.Cursor = Windows.Forms.Cursors.WaitCursor TransactionBegin() ' ----- Save the data. If (ActiveID = -1) Then ' ----- Create a new entry. sqlText = "INSERT INTO GroupName (FullName) " & _ "OUTPUT INSERTED.ID VALUES (" & _ DBText(Trim(RecordFullName.Text)) & ")" newID = CInt(ExecuteSQLReturn(sqlText)) Else ' ----- Update the existing entry. newID = ActiveID sqlText = "UPDATE GroupName SET FullName = " & _ DBText(Trim(RecordFullName.Text)) & _ " WHERE ID = " & ActiveID ExecuteSQL(sqlText) End If ' ----- Clear any existing security settings. sqlText = "DELETE FROM GroupActivity " & _ "WHERE GroupID = " & newID ExecuteSQL(sqlText) ' ----- Save the selected security settings. For Each itemChecked As ListItemData In _ ActivityList.CheckedItems sqlText = "INSERT INTO GroupActivity (GroupID, " & _ "ActivityID) VALUES (" & newID & ", " & _ itemChecked.ItemData & ")" ExecuteSQL(sqlText) Next itemChecked ' ----- Complete all changes. TransactionCommit() ActiveID = newID ' ----- This change may affect this user. If (LoggedInGroupID = ActiveID) Then _ ReprocessSecuritySet() ' ----- Success. ActiveID = newID Me.Cursor = Windows.Forms.Cursors.Default Return True ErrorHandler: Me.Cursor = Windows.Forms.Cursors.Default GeneralError("GroupName.SaveFormData", Err.GetException()) TransactionRollback() Return False End Function
Be sure to check out the other routines in the GroupName form; they exist to support and enhance the user experience.
We also need a form to manage records in the UserName table. Because the code for that form generally follows what we've already seen in the GroupName form, I won't bore you with the details. I've already added UserName.vb to your project, but to prevent bugs in your code while you were in the middle of development, I disabled it (at least in the "Before" version of the code). To enable it, select the file in the Solution Explorer window. Then in the Properties panel, change the Build Action property from "None" to "Compile."
The only interesting code in this form that is somewhat different from the GroupName form is the handling of the password. To keep things as secure as possible, I don't actually load the saved password into the on-form Password field. It wouldn't do any good anyway because I've stored the encrypted version.
Because I use the user's Login ID as the secret key when encrypting the password, I must regenerate the password if the user ever changes the Login ID. The private OrigLoginID field keeps a copy of the Login ID when the form first opens, and checks for any changes when resaving the record. If changes occur, it regenerates the password.
passwordResult = EncryptPassword(Trim(RecordLoginID.Text), _ Trim(RecordPassword.Text))
Using the UserName and GroupName editing forms requires some additional code in the main form. Add it to the body of the General module.
Insert Chapter 11, Snippet Item 8.
The AdminLinkGroups and AdminLinkUsers controls are web-style link labels that we added to the program a few chapters back. The LinkClicked eventnot the Clicked eventtriggers the display of the code editor. Here's the code to edit the GroupName table.
Private Sub AdminLinkGroups_LinkClicked( _ ByVal sender As Object, ByVal e As System.Windows. _ Forms.LinkLabelLinkClickedEventArgs) _ Handles AdminLinkGroups.LinkClicked ' ----- Let the user edit the list of security groups. If (SecurityProfile(LibrarySecurity.ManageGroups) _ = False) Then MsgBox(NotAuthorizedMessage, MsgBoxStyle.OkOnly Or _ MsgBoxStyle.Exclamation, ProgramTitle) Return End If ' ----- Edit the records. ListEditRecords.ManageRecords(New Library.GroupName) ListEditRecords = Nothing End Sub
Now that we have all of the security support code added to the project, we can start using those features to change the application experience for patrons and administrators. It's not polite to tempt people with immense power, so it's best to hide those features that are not accessible to the lowly and inherently less powerful patron users.
First, let's provide the power of differentiation by adding the administrative login form, shown in Figure 11-2.
Figure 11-2. The official Library Project administrative login form
I've already added the ChangeUser.vb form to the project. If you're using the "Before" version of this chapter's code, select ChangeUser.vb in the Solution Explorer. Then change its Build Action property (in the Properties panel) from "None" to "Compile," just as you did with the UserName.vb form.
All of the hard work occurs in the form's ActOK_Click event. If the user selects the Return to Patron Mode option, all security values are cleared, and the Main form hides most features (through code added later).
LoggedInUserID = -1 LoggedInUserName = "" LoggedInGroupID = -1 ReprocessSecuritySet()
This form gets connected into the application through the Main form's ActLogin _Click event. Open the MainForm.vb file, double-click on the Login button in the upper-right corner, and add the following code to the Click event template that appears.
Insert Chapter 11, Snippet Item 9.
' ----- Prompt the user for patron or administrative mode. ShowLoginForm()
That wasn't much code. Add the ShowLoginForm method's code to the form as well.
Insert Chapter 11, Snippet Item 10.
Private Sub ShowLoginForm() ' ----- Prompt the user for patron or administrative mode. Dim userChoice As Windows.Forms.DialogResult userChoice = ChangeUser.ShowDialog() ChangeUser = Nothing If (userChoice = Windows.Forms.DialogResult.OK) Then _ UpdateDisplayForUser() End Sub
Let's also enable the F12 key to act as a login trigger. Add the following code to the Select Case statement in the MainForm_KeyDown event handler.
Insert Chapter 11, Snippet Item 11.
Case Keys.F12 ' ----- Prompt the user for patron or administrative mode. ShowLoginForm() e.Handled = True
The ShowLoginForm routine calls another method, UpdateDisplayForUser, that hides and shows various display elements on the main form based on the security profile of the current user. Add it to the MainForm class code. I won't show the code here, but basically it looks at the LoggedInUserID variable, and if it is set to 1, it hides all of the controls for advanced features.
Insert Chapter 11, Snippet Item 12.
Currently, when you run the application, all of the advanced features appear, even though no administrator has supplied an ID or password. Calling UpdateDisplayForUser when the Main form first appears solves that problem. Add the following code to the end of the MainForm_Load method.
Insert Chapter 11, Snippet Item 13.
' ----- Prepare for a patron user. UpdateDisplayForUser()
The last update (five updates, actually) involves limiting the major sections of the form to just authorized administrators. For instance, only administrators who are authorized to run reports should be able to access the reporting panel on the main form. Locate the TaskReports method in the Main form, and find the line that displays the panel.
PanelReports.Visible = True
Replace this line with the following code.
Insert Chapter 11, Snippet Item 14.
If (SecurityProfile(LibrarySecurity.RunReports)) Then _ PanelReports.Visible = True
We need to do the same thing in the TaskCheckOut, TaskCheckIn, TaskAdmin, and TaskProcess methods. In each case, replace the line that reads:
Panel???.Visible = True
with code that checks the security settings before showing the panel.
Insert Chapter 11, Snippet Items 15 through 18.
Run the program, and you'll see that it's starting to look like a real application. If you want access to the enhanced features, try a Login ID of "admin" with no password. You can change that through the UserName form if you want!
Because we have a way to secure access to the data and features of the Library Project, let's move to the next chapter and start focusing on the data, the focal point of any business application.