ASP.NET Forms Authentication – Security

ASP.NET Forms authentication allows users to identify themselves by entering credentials (a user name and password) into a Web Form. Upon receipt of these credentials, the Web application can authenticate the user by checking the user name and password combination against a data source.

This How To describes how to authenticate users against the Microsoft Active Directory directory service by using the Lightweight Directory Access Protocol (LDAP). It also describes how to retrieve a list of security groups and distribution lists to which the user belongs, how to store this information in a GenericPrincipal object, and how to store this into the HttpContext.Current.User property that flows with the request through the ASP.NET Web application. This can subsequently be used for .NET role-based authorization.

Summary of Steps

This How To includes the following steps:

  • Step 1. Create a Web Application with a Logon Page
  • Step 2.Configure the Web Application for Forms Authentication
  • Step 3.Develop LDAP Authentication Code to Look Up the User in Active Directory
  • Step 4.Develop LDAP Group Retrieval Code to Look Up the User’s Group Membership
  • Step 5.Authenticate the User and Create a Forms Authentication Ticket
  • Step 6.Implement an Authentication Request Handler to Construct a GenericPrincipal Object
  • Step 7.Test the Application

Step 1. Create a Web Application with a Logon Page

This procedure creates a simple C# Web application that contains a logon page that allows a user to enter a user name and password and a default page that displays the identity name and group membership information associated with the current Web request.

To create a Web application with a logon page

  1. Start Microsoft Visual Studio® .NET and create a new C# ASP.NET Web Application named FormsAuthAD.
  2. Use Solution Explorer to rename WebForm1.aspx as Logon.aspx.
  3. Add a new assembly reference to System.DirectoryServices.dll. This provides access to the System.DirectoryServices namespace that contains managed types to help with Active Directory querying and manipulation.
  4. Add the controls listed in Table 1 to Logon.aspx to create a simple logon form.Table 1. Logon.aspx controls
    Control Type Text ID
    Label Domain Name:
    Label User Name:
    Label Password
    Text Box txtDomainName
    Text Box txtUserName
    Text Box txtPassword
    Button Log On btnLogon
    Label lblError
  5. Set the TextMode property of txtPassword to Password.
  6. In Solution Explorer, right-click FormsAuthAd, point to Add, and then click Add Web Form.
  7. In the Name field, type default.aspx, and then click Open.
  8. In Solution Explorer, right-click default.aspx, and then click Set As Start Page.
  9. Double-click default.aspx to display the page load event handler.
  10. Add the following code to the event handler to display the identity name associated with the current Web request.
    Response.Write( HttpContext.Current.User.Identity.Name );

Step 2. Configure the Web Application for Forms Authentication

This procedure edits the application’s Web.config file to configure the application for Forms authentication.

To configure the Web application for forms authentication

  1. Use Solution Explorer to open Web.config.
  2. Locate the <authentication> element and change the mode attribute to Forms.
  3. Add the following <forms> element as a child of the authentication element and set the loginUrl, name, timeout, and pathattributes as shown in the following.
    <authentication mode="Forms">
      <forms loginUrl="logon.aspx" name="adAuthCookie" timeout="60"
        path="/">
      </forms>
    </authentication>
  4. Add the following <authorization> element beneath the <authentication> element. This will allow only authenticated users to access the application. The previously establish loginUrl attribute of the <authentication> element will redirect unauthenticated requests to the logon.aspx page.
    <authorization>
      <deny users="?" />
      <allow users="*" />
    </authorization>
  5. Save Web.config.
  6. Start the IIS Microsoft Management Console (MMC) snap-in.
  7. Right-click the application’s virtual directory, and then click Properties.
  8. Click the Directory Security tab, and then click the Edit button in the Anonymous access and authentication control group.
  9. Select the Anonymous access check box and clear the Allow IIS to control password check box.
  10. Because the default anonymous account IUSR_MACHINE does not have permission to access Active Directory, create a new least privileged account and enter the account details in the Authentication Methods dialog box.
  11. Click OK, and then click OK again to close the Properties dialog box.
  12. Return to Visual Studio .NET and add an <identity> element beneath the <authorization> element in Web.config and set the impersonate attribute to true. This causes ASP.NET to impersonate the anonymous account specified earlier.
    <identity impersonate="true" />

    As a result of this configuration, all requests to the application will run under the security context of the configured anonymous account. The user will provide credentials through the Web form to authenticate against Active Directory, but the account used to access Active Directory will be the configured anonymous account.

Step 3. Develop LDAP Authentication Code to Look Up the User in Active Directory

This procedure adds a new helper class to the Web application to encapsulate the LDAP code. The class will initially provide an IsAuthenticated method to validate a supplied domain, user name, and password against an Active Directory user object.

To develop LDAP authentication code to look up the user in Active Directory

  1. Add a new C# class file called LdapAuthentication.cs.
  2. Add a reference to the System.DirectoryServices.dll assembly.
  3. Add the following usingstatements to the top of LdapAuthentication.cs.
    using System.Text;
    using System.Collections;
    using System.DirectoryServices;
  4. Rename the existing namespace as FormsAuthAD.
  5. Add two private strings to the LdapAuthenticationclass; one to hold the LDAP path to Active Directory and the other to hold a filter attribute used for searching Active Directory.
    private string _path;
    private string _filterAttribute;
  6. Add a public constructor that can be used to initialize the Active Directory path.
    public LdapAuthentication(string path)
    {
      _path = path;
    }
  7. Add the following IsAuthenticated method that accepts a domain name, user name and password as parameters and returns bool to indicate whether or not the user with a matching password exists within Active Directory. The method initially attempts to bind to Active Directory using the supplied credentials. If this is successful, the method uses the DirectorySearcher managed class to search for the specified user object. If located, the _path member is updated to point to the user object and the _filterAttributemember is updated with the common name attribute of the user object.
    public bool IsAuthenticated(string domain, string username, string
      pwd)
    {
      string domainAndUsername = domain + @"" + username;
      DirectoryEntry entry = new DirectoryEntry( _path,
                                                 domainAndUsername,
                                                   pwd);
    
      try
      {
        // Bind to the native AdsObject to force authentication.
        Object obj = entry.NativeObject;
        DirectorySearcher search = new DirectorySearcher(entry);
        search.Filter = "(SAMAccountName=" + username + ")";
        search.PropertiesToLoad.Add("cn");
        SearchResult result = search.FindOne();
        if(null == result)
        {
          return false;
        }
        // Update the new path to the user in the directory
        _path = result.Path;
        _filterAttribute = (String)result.Properties["cn"][0];
      }
      catch (Exception ex)
      {
        throw new Exception("Error authenticating user. " + ex.Message);
      }
      return true;
    }

Step 4. Develop LDAP Group Retrieval Code to Look Up the User’s Group Membership

This procedure extends the LdapAuthentication class to provide a GetGroups method, which will retrieve the list of groups that the current user is a member of. The GetGroups method will return the group list as a pipe separated string, as in the following.

"Group1|Group2|Group3|"

To develop LDAP group retrieval code to look up the user’s group membership

  1. Add the following implementation of the GetGroups method to the LdapAuthenticationclass.
    public string GetGroups()
    {
      DirectorySearcher search = new DirectorySearcher(_path);
      search.Filter = "(cn=" + _filterAttribute + ")";
      search.PropertiesToLoad.Add("memberOf");
      StringBuilder groupNames = new StringBuilder();
      try
      {
        SearchResult result = search.FindOne();
        int propertyCount = result.Properties["memberOf"].Count;
        String dn;
        int equalsIndex, commaIndex;
    
        for( int propertyCounter = 0; propertyCounter < propertyCount;
             propertyCounter++)
        {
          dn = (String)result.Properties["memberOf"][propertyCounter];
    
          equalsIndex = dn.IndexOf("=", 1);
          commaIndex = dn.IndexOf(",", 1);
          if (-1 == equalsIndex)
          {
            return null;
          }
          groupNames.Append(dn.Substring((equalsIndex + 1),
                            (commaIndex - equalsIndex) - 1));
          groupNames.Append("|");
        }
      }
      catch(Exception ex)
      {
        throw new Exception("Error obtaining group names. " +
          ex.Message);
      }
      return groupNames.ToString();
    }

Step 5. Authenticate the User and Create a Forms Authentication Ticket

This procedure implements the btnLogon_Click event handler to authenticate users. For authenticated users, you will then create a Forms authentication ticket that contains the user’s group list. You will then redirect the user to the original page that they requested (before being redirected to the logon page).

To authenticate the user and create a forms authentication ticket

  1. Return to the Logon.aspx form and double-click the Log On button to create an empty btnLogon_Click event handler.
  2. At the top of the file add the following using statement beneath the existing using statements. This provides access to the FormsAuthenticationmethods.
    using System.Web.Security;
  3. Add code to create a new instance of the LdapAuthenticationclass initialized to point to your LDAP Active Directory, as shown in the following code. Remember to change the path to point to your Active Directory server.
    // Path to you LDAP directory server.
    // Contact your network administrator to obtain a valid path.
    string adPath =
      "LDAP://yourCompanyName.com/DC=yourCompanyName,DC=com";
    LdapAuthentication adAuth = new LdapAuthentication(adPath);
  4. Add the code that follows to perform the following steps:
    1. Authenticate the caller against Active Directory.
    2. Retrieve the list of groups that the user is a member of.
    3. Create a FormsAuthenticationTicket that contains the group list.
    4. Encrypt the ticket.
    5. Create a new cookie that contains the encrypted ticket.
    6. Add the cookie to the list of cookies returned to the user’s browser.
    try
    {
      if(true == adAuth.IsAuthenticated(txtDomainName.Text,
                                        txtUserName.Text,
                                        txtPassword.Text))
      {
        // Retrieve the user's groups
        string groups = adAuth.GetGroups();
        // Create the authetication ticket
        FormsAuthenticationTicket authTicket =
            new FormsAuthenticationTicket(1,  // version
                                          txtUserName.Text,
                                          DateTime.Now,
                                          DateTime.Now.AddMinutes(60),
                                          false, groups);
        // Now encrypt the ticket.
        string encryptedTicket =
          FormsAuthentication.Encrypt(authTicket);
        // Create a cookie and add the encrypted ticket to the
        // cookie as data.
        HttpCookie authCookie =
                     new HttpCookie(FormsAuthentication.FormsCookieName,
                                    encryptedTicket);
        // Add the cookie to the outgoing cookies collection.
        Response.Cookies.Add(authCookie);
    
        // Redirect the user to the originally requested page
        Response.Redirect(
                  FormsAuthentication.GetRedirectUrl(txtUserName.Text,
                                                     false));
      }
      else
      {
        lblError.Text =
             "Authentication failed, check username and password.";
      }
    }
    catch(Exception ex)
    {
      lblError.Text = "Error authenticating. " + ex.Message;
    }

Step 6. Implement an Authentication Request Handler to Construct a GenericPrincipal Object

This procedure implements the Application_AuthenticateRequest event handler within global.asax and creates a GenericPrincipal object for the currently authenticated user. This will contain the list of groups that the user is a member of, retrieved from the FormsAuthenticationTicket contained in the authentication cookie. Finally, you will associate the GenericPrincipal object with the current HttpContext object that is created for each Web request.

To implement an authentication request handler to construct a GenericPrincipal object

  1. Use Solution Explorer to open global.asax.cs.
  2. Add the following usingstatements to the top of the file.
    using System.Web.Security;
    using System.Security.Principal;
  3. Locate the Application_AuthenticateRequest event handler and add the following code to obtain the cookie that contains the encrypted FormsAuthenticationTicket, from the cookie collection passed with the request.
    // Extract the forms authentication cookie
    string cookieName = FormsAuthentication.FormsCookieName;
    HttpCookie authCookie = Context.Request.Cookies[cookieName];
    
    if(null == authCookie)
    {
      // There is no authentication cookie.
      return;
    }
  4. Add the following code to extract and decrypt the FormsAuthenticationTicketfrom the cookie.
    FormsAuthenticationTicket authTicket = null;
    try
    {
      authTicket = FormsAuthentication.Decrypt(authCookie.Value);
    }
    catch(Exception ex)
    {
      // Log exception details (omitted for simplicity)
      return;
    }
    
    if (null == authTicket)
    {
      // Cookie failed to decrypt.
      return;
    }
  5. Add the following code to parse out the pipe separate list of group names attached to the ticket when the user was originally authenticated.
    // When the ticket was created, the UserData property was assigned a
    // pipe delimited string of group names.
    String[] groups = authTicket.UserData.Split(new char[]{'|'});
  6. Add the following code to create a GenericIdentity object with the user name obtained from the ticket name and a GenericPrincipalobject that contains this identity together with the user’s group list.
    // Create an Identity object
    GenericIdentity id = new GenericIdentity(authTicket.Name,
                                             "LdapAuthentication");
    
    // This principal will flow throughout the request.
    GenericPrincipal principal = new GenericPrincipal(id, groups);
    // Attach the new principal object to the current HttpContext object
    Context.User = principal;

Step 7. Test the Application

This procedure uses the Web application to request the default.aspx page. You will be redirected to the logon page for authentication. Upon successful authentication, your browser will be redirected to the originally requested default.aspx page. This will extract and display the list of groups that the authenticated user belongs to from the GenericPrincipal object that has been associated with the current request by the authentication process.

To test the application

  1. On the Build menu, click Build Solution.
  2. In Solution Explorer, right-click default.aspx, and then click View in Browser.
  3. Enter a valid domain name, user name, and password and then click Log On.
  4. If you are successfully authenticated, you should be redirected back to default.aspx. The code on this page should display the user name of the authenticated user.To see the list of groups the authenticated user is a member of, add the following code at the end of the Application_AuthenticateRequest event handler in the global.aspx.cs file.
    Response.Write("Groups: " + authTicket.UserData + "<br>");
Advertisements

International currency facts of SQL Server (2000, 2005)

SQL Server’s currency data types have some interesting international features. And some of the intricacies of those features have some interesting international implications. I figured as long as we were here I could talk about some of them….

The money and smallmoney topic in MSDN gives the basics of the datatypes:

money

Monetary data values from -2^63 (-922,337,203,685,477.5808) through
2^63 – 1 (+922,337,203,685,477.5807), with accuracy to a ten-thousandth of a monetary unit. Storage size is 8 bytes.

smallmoney

Monetary data values from – 214,748.3648 through +214,748.3647, with accuracy to a ten-thousandth of a monetary unit. Storage size is 4 bytes.

Both numbers are pretty much scaled integer types rather than floating point values (the latter would freak out a lot of people when it comes to money, so it makes sense to build the types this way). Though of course if you need more than four decimal places it is recommended that use the Decimal data type (there are apparently currencies for whom it is recommended to store more than four decimal places to help with complex calculations).

You also cannot include currency grouping separators (commas for en-US) unless you pass the value as a string — in which case you will want to be sure that the currency grouping and decimal separators all match the language of the session you are in. I usually like to put in straight numbers and not worry about dependencies on the language settings, myself.

But the really interesting information is in the topic entitled Using Monetary Data. What this datatype allows is any currency symbol to be put in front of the number, even if it is not in a string (enclosed by single quotes) in a Transact-SQL clause. Basically, all of the following currency signs are supported:

Codepoint

Symbol

Name

U+0024

$

DOLLAR SIGN

U+00a3

£

POUND SIGN

U+00a4

¤

CURRENCY SIGN

U+00a5

¥

YEN SIGN

U+09f2

BENGALI RUPEE MARK

U+09f3

BENGALI RUPEE SIGN

U+0e3f

฿

THAI CURRENCY SYMBOL BAHT

U+20a1

COLON SIGN

U+20a2

CRUZEIRO SIGN

U+20a3

FRENCH FRANC SIGN

U+20a4

LIRA SIGN

U+20a6

NAIRA SIGN

U+20a7

PESETA SIGN

U+20a8

RUPEE SIGN

U+20a9

WON SIGN

U+20aa

NEW SHEQEL SIGN

U+20ab

DONG SIGN

U+20ac

EURO SIGN

As a bit of trivia, if you look at the Using Monetary Data topic, it has the same table as above, sorted in code point order, with one exception: the Euro (U+20ac) is placed just before U+20a1. The reason for this is that once upon a time (in the original Books Online topic that shipped with the initial release of SQL Server 2000), the documentation listed U+20a0 as the euro.

Now the code in SQL Server did not do this (since that was not really the euro), and if you tried to use U+20a0 (, a.k.a. EURO-CURRENCY SIGN) as a currency sign in a money or smallmoney column, it would not work.

When they finally fixed the documentation, it was I suppose easier to update the table by updating the two entries without moving stuff around in the table….

Interestingly. I just looked in SQL Server 2005 Books Online and this table has not been updated there, either to add new entries or to fix that one ordering issue. Oops. But that is kind of minor, no sense worrying about that….

Now for the real problems — you knew there would be real problems, didn’t you? 🙂

There are 22 characters in the Currency Symbols block (only 11 of which SQL Server recognizes in this case). Most importantly, there are 41 characters in the Sc (Symbol, Currency) general category (only 18 of which SQL Server recognizes in this case). For both of these you can look to the links to see the list of currency symbols….

I would be a lot happier if SQL Server were looking for a return of UnicodeCategory.CurrencySymbol from CharUnicodeInfo.GetUnicodeCategory or some other convenient way of getting the currency symbols and treating them that way, of course.

Or alternately, it would be cool if they removed some of the items that no longer really exist since they have converted to the euro now, and maybe added some more in.

However, I will now take a step back and not ask for those features just yet….

Note that you can just insert your currency values with any of these currency symbols in front of them. And the values will be inserted. As Is. Which may not be what you want if you deal with €100 vs. ¥100 vs. ₩100 for example (since €100 is about ¥13,541 or ₩126,178 by today’s fix!).

Now that currency symbol is not stored, either — the currency’s identity is eliminated after the insert. Fill in your own disaster sequence on this one — and make sure to be careful of what you insert in your application….

Now I have worked with the Cloanto Currency Server several times, and would highly recommend them to people who would want to deal with different types of currencies and do conversions. It is pretty cool having the results at your fingertips and available through both automation and .NET, too. 

Anyway, the best practice for SQL Server is just keep the money and smallmoney columns with a single currency, or store the currency type in another field. It will keep you from doing something you did not intend to do with the database. Big mistakes (where big is defined as scope of effect; the actual mistake is usually a small design issue) in these sorts of columns are the surest way to find oneself looking for another job….

Accessing the network printer from ASP.NET VB.NET

I managed to put together several pieces of code from the web to allow network printing. Most of the examples I found were written in C#.

This script can be useful for placing orders with warehouses etc that allow access to their network printers.. etc.

<%@ outputcache location="None" %>
<%@ Page Language="VB" Debug="True"%>
<%@ Import Namespace="System" %>
<%@ Import Namespace="System.Collections" %>
<%@ Import Namespace="System.Collections.Specialized" %>
<%@ Import Namespace="System.ComponentModel" %>
<%@ Import Namespace="System.IO" %>
<%@ Import Namespace="System.Drawing" %>
<%@ Import Namespace="System.Drawing.Printing " %>
<%@ Import Namespace="System.Text" %>
<%@ Import Namespace="System.Web" %>


Sub Submit_Click(ByVal sender As Object, ByVal e As EventArgs)
Try
Dim pd as New PrintDocument()

' Set the printer name.
pd.PrinterSettings.PrinterName = "NS5hpoffice"
AddHandler pd.PrintPage, AddressOf Me.pd_PrintPage
pd.Print()

Catch ex as Exception
Response.Write("Error: " & ex.ToString)

End Try
End Sub

Private Sub pd_PrintPage(ByVal sender as Object, ByVal ev as PrintPageeventArgs)
Dim yPos as Single = 250
Dim leftMargin as Single = ev.MarginBounds.Left
Dim topMargin as Single = ev.MarginBounds.Top
Dim printFont = New Font("Arial", 10)
Dim sb As StringBuilder =  New StringBuilder()

' Page title and date/time.
sb.Append("Warehouse Shipment Request")
sb.Append(Environment.NewLine)
sb.Append("DateTime: " + DateTime.Now.ToString()+ Environment.NewLine)

' Iterate submitted form fields and get field names.
Dim fieldValue As String
Dim fieldName As String

' Exclude viewstate and submit button.
For Each fieldName In HttpContext.Current.Request.Form
If fieldName = "__VIEWSTATE" Or fieldName = "Submit" Then
Else
    ' Get the field values.
    fieldValue = HttpContext.Current.Request.Form(fieldName)
    ' Add the field names and values to the page.
    ' Break the field values into 50 character segments so it will fit on the paper.
    ' Currently, this only accounts for fields of l50 characters or less.
     ' ISSUE: breaks in the middle of words instead of spaces
    If fieldValue.Length > 100 Then
              sb.Append(fieldName + ": " + fieldValue.Substring(0,50) + Environment.NewLine)
              sb.Append("            " + fieldValue.Substring(50,50) + Environment.NewLine)
              sb.Append("            " + fieldValue.Substring(100,fieldValue.Length - 100) + Environment.NewLine)
    Else If fieldValue.Length > 50 Then
              sb.Append(fieldName + ": " + fieldValue.Substring(0,50) + Environment.NewLine)
              sb.Append("            " + fieldValue.Substring(50,fieldValue.Length - 50) + Environment.NewLine)
    Else
              sb.Append(fieldName + ": " + fieldValue + Environment.NewLine)
    End If
 End If
Next
ev.Graphics.DrawString(sb.ToString(), printFont, Brushes.Black, leftMargin, yPos, New StringFormat())
End Sub



<html>

<head>
    <title>Network Printing</title>
</head>
<body>
    <form runat="server">
        <table width="456" border="1" align="center" cellpadding="3" cellspacing="0">
            <tbody>
                <tr>
                    <td colspan="2">
                        <p>Network Printing</p>
                  </td>
                </tr>
                <tr>
                    <td width="148">
                        Quantity&nbsp;:</td>
                    <td width="290">
                        <asp:TextBox id="Quantity" width="25" text="0" runat="server" />

                    </td>
                </tr>
                <tr>
                    <td>
                        Inventory&nbsp;Number&nbsp;:</td>
                    <td>
                        <asp:TextBox id="InvNumber" text="12345" runat="server" />
                    </td>
                </tr>
				<tr>
                    <td>
                       Price&nbsp;:</td>
                    <td>
                        <asp:TextBox id="Price" text="12.50" runat="server" />
                    </td>
                </tr>
			<tr>
                    <td>
                        Description&nbsp;:</td>
                    <td>
                        <asp:TextBox id="Description" Text="Were having fun now!" runat="server" />
                    </td>
                </tr>
				   <tr>
                    <td colspan="2">
                        <asp:Button id="Submit" onclick="Submit_Click" runat="server" Text="Submit" />
                   </td
               </tr>
           </tbody>
      </table>
</form>
</body>
</html>

Seems to work great on Intranet. Haven’t had the opportunity to try it external. Let me know how it does if you do.

Hover and Active in IE CSS Specification

Pseudo-Classes Fix for Internet Explorer

Despite the fact that it has been almost six years since CSS 2 specification became a W3C recommendation, Internet Explorer, the dominating browser that is being forced
onto unsuspecting public my Microsoft Corporation, still fails to implement pseudo-classes, such as :hover and :active, for all but anchor elements.

Majority of web developers redeem this problem by polluting thier HTML with endless onmouseover and onmouseout handlers. Very few realize that the time Microsoft could not find to create a compliant browser was wasted on development of proprietory features, which often are the way to work around those limitations. In this particular case, while :hover and :active pseudo-classes do not behave as we expect them to on all but
one element, there are Internet Explorer Behaviors to add the desired functionality with little extra work. The scripting of the behavior goes into a separate .htc file which compliant
browsers would not ever see:

IEFixes.htc
<PUBLIC:ATTACH EVENT="onmouseover" ONEVENT="DoHover()" />
<PUBLIC:ATTACH EVENT="onmouseout"  ONEVENT="RestoreHover()" />
<PUBLIC:ATTACH EVENT="onmousedown" ONEVENT="DoActive()" />
<PUBLIC:ATTACH EVENT="onmouseup"   ONEVENT="RestoreActive()" />

function DoHover()
  { element.className += ' hover';
}

function DoActive()
{ element.className += ‘ active’;
}

function RestoreHover()
{ element.className = element.className.replace(/bhoverb/,”);
}

function RestoreActive()
{ element.className = element.className.replace(/bactiveb/,”);
}

The behavior is attached to the desired elements using CSS declaration:

button, tr, td
  { behavior: url('IEFixes.htc');
  }

The .hover and .active classes to be used by IE are
declared along with the :hover and :active pseudo-classes:

button:active, button.active
  { /*Active styles here */}
button:hover, button.hover
  { /*Hover styles here */}

Examples that use hover and active styling

Button

Table
Column 1 Column 2 Column 3
Cell 1:1 Cell 1:2 Cell 1:3
Cell 2:1 Cell 2:2 Cell 2:3
Cell 3:1 Cell 3:2 Cell 3:3