Как проверить правильность имени пользователя и пароля в Windows NT

Операционная система Windows NT включает средства разграничения доступа, позволяющие разрешать или запрещать доступ определенным пользователям к тем или иным ресурсам системы. Эти средства были бы совершенно бесполезными, если бы не было возможности удостовериться, что с компьютером работает именно пользователь, соответствующий учетной записи, а не кто-то другой, выдающий себя за него. Механизм, с помощью которого пользователь подтверждает свою подлинность, называется аутентификацией

Введение

Операционная система Windows NT включает средства разграничения доступа, позволяющие разрешать или запрещать доступ определенным пользователям к тем или иным ресурсам системы. Эти средства были бы совершенно бесполезными, если бы не было возможности удостовериться, что с компьютером работает именно пользователь, соответствующий учетной записи, а не кто-то другой, выдающий себя за него. Механизм, с помощью которого пользователь подтверждает свою подлинность, называется аутентификацией. Аутентификация производится на основании того или иного секретного элемента (аутентификатора), которым владеет только владелец учетной записи. Как правило, таким аутентификатором является пароль пользователя, однако это может быть и смарт-карта, и биометрические данные пользователя — Windows NT позволяет сторонним разработчикам расширять механизмы аутентификации, встроенные в систему.

Windows NT производит аутентификацию при входе пользователя в систему. В большинстве случаев этого достаточно, так как система автоматически выполняет разграничение доступа на основании имени пользователя, вошедшего в систему. Более того, Microsoft не рекомендует встраивать собственные механизмы аутентификации в приложения, следуя принципу единого входа (Unified Logon) — пользователь должен ввести имя пользователя и пароль только один раз при входе в систему. Оснований для такой рекомендации несколько:

  • Дополнительные запросы имени пользователя и пароля могут быть раздражительными для пользователя.
  • Неаккуратное программирование механизмов аутентификации может внести брешь в безопасность операционной системы.
  • Windows NT допускает расширение механизмов аутентификации. Программы, рассчитанные на конкретный способ аутентификации, могут оказаться непригодными, если системой используется другой способ.

Если же вы считаете, что проверка имени пользователя и пароля в вашей программе совершенно необходима, то эта статья для вас. Мы рассмотрим два способа аутентификации:

  • С помощью функции LogonUser. Это наиболее простой способ, который, однако требует наличия специальных привилегий у вызывающего в Windows NT и Windows 2000 (но не в Windows XP).
  • С помощью Security Support Provider Interface (SSPI). Этот метод более сложен, но он не требует дополнительных привилегий. Кроме того, он пригоден для удаленной аутентификации и, при выполнении некоторых условий, работает в Windows 9x/Me.

Также будет рассмотрен способ, основанный на функции NetUserChangePassword, который некоторые рекомендуют использовать для проверки паролей. Я попробую вас убедить не использовать этот метод, несмотря на кажущуюся его простоту.

Каждый метод будет иллюстрироваться с помощью функции, имеющей следующий прототип:


BOOL CheckPassword_Method(
IN PCTSTR pszDomainName, // имя домена
IN PCTSTR pszUserName, // имя пользователя
IN PCTSTR pszPassword, // пароль
OUT PHANDLE phToken // токен пользователя
);

Фунция принимает на вход имя учетной записи, пароль и имя домена, которому принадлежит учетная запись. Имя домена может быть указано как NULL, в таком случае функция выбирает домен самостоятельно. Если имя пользователя и пароль указаны правильно, функция возвращает TRUE. Если же имя пользователя или пароль указаны неверно, или прозошла какая-либо ошибка, функция возвращает FALSE, и код ошибки, как обычно, может быть получен с помощью GetLastError. Кроме того, функция возвращает так называемый токен пользователя (известный также в русскоязычной версии Windows NT под названием маркерный объект). Этот токен понадобится, если в дальшейшем требуется выполнить некоторые действия от лица аутентифицированного пользователя. В противном случае можно указать параметр phToken как NULL.

Статью сопровождает небольшое тестовое приложение, которое иллюстрирует все перечисленные методы.

Функция LogonUser

Функция LogonUser в Win32 API непосредственно предназначена для решения задачи аутентификации.


BOOL LogonUser(
PTSTR pszUsername, // имя пользователя
PTSTR pszDomain, // имя домена
PTSTR pszPassword, // пароль
DWORD dwLogonType, // тип входа в систему
DWORD dwLogonProvider, // провайдер входа в систему
PHANDLE phToken // токен пользователя
);

Назначение первых трех параметров функции очевидно. Некоторые ранние версии Windows NT не позволяли указывать NULL в качестве имени домена. Этот случай необходимо обрабатывать отдельно, если требуется, чтобы процедура аутентификации работала во всех версиях операционной системы.

Параметр dwLogonType указывает способ входа в систему. Ниже перечислены некоторые допустимые значения для этого параметра, которые имеют смысл для рассматриваемой задачи (с полным списком можно ознакомиться в документации функции LogonUser в MSDN).

Основное различие между способами входа в систему - это привилегии, требуемые от проверяемой учетной записи, и набор групп, заносимых в результирующий токен.

Так, если производится вход в систему в качетсве пакетного задания, то учетная запись, заданная параметром pszUsername, должна иметь привилегию SE_BATCH_LOGON_NAME, а в токен пользователя будет добавлена группа Batch (S-1-5-3). Помимо этого, между способами входа в систему существуют более тонкие различия, которые приведены в документации LogonUser. Для решения поставленной задачи мы будем использовать способ входа LOGON32_LOGON_NETWORK, так как это наиболее быстрый способ. Это означает, что проверяемые нашей функцией пользователи должны иметь право сетевого входа в систему (по умолчанию все пользователи имеют это право).

Параметр dwLogonProvider указывает какой провайдер входа в систему следует использовать. Мы будем использовать значение LOGON32_PROVIDER_DEFAULT, который означает использование провайдера по умолчанию. Наконец, параметр phToken является указателем на переменную, в которую функция при успешном завершении заносит токен пользователя.

Листинг 1 содержит функцию CheckPassword_LogonUser, которая использует LogonUser для проверки имени пользователя и пароля.

Листинг 1. Проверка имени пользователя и пароля с помощью LogonUser


BOOL CheckPassword_LogonUser(
IN PCTSTR pszDomainName,
IN PCTSTR pszUserName,
IN PCTSTR pszPassword,
OUT PHANDLE phToken
)
{
_ASSERTE(pszUserName != NULL);
_ASSERTE(pszPassword != NULL);

HANDLE hToken;

TCHAR szDomainName[DNLEN + 1];
TCHAR szUserName[UNLEN + 1];
TCHAR szPassword[PWLEN + 1];

if (pszDomainName == NULL)
{
BYTE bSid[8 + 4 * SID_MAX_SUB_AUTHORITIES];
ULONG cbSid = sizeof(bSid);
ULONG cchDomainName = countof(szDomainName);
SID_NAME_USE Use;

if (!LookupAccountName(NULL, pszUserName, (PSID)bSid, &cbSid,
szDomainName, &cchDomainName, &Use))
return FALSE;
}
else
lstrcpyn(szDomainName, pszDomainName, countof(szDomainName));

lstrcpyn(szUserName, pszUserName, countof(szUserName));
lstrcpyn(szPassword, pszPassword, countof(szPassword));

if (!LogonUser(szUserName, szDomainName, szPassword,
LOGON32_LOGON_NETWORK, LOGON32_PROVIDER_DEFAULT,
&hToken))
return FALSE;

if (phToken == NULL)
_VERIFY(CloseHandle(hToken));
else
*phToken = hToken;

return TRUE;
}

Хочу обратить внимание на несколько моментов в исходном коде функции. CheckPassword_LogonUser. Во-первых, все входные строки копируются в массивы, выделенные на стеке функции. Это необходимо, так как параметры функции объявлены как константные строки, в то время как LogonUser принимает указатели на модифицируемые строки. И в самом деле, при некоторых условиях LogonUser может осуществлять запись в передаваемые ей параметры, что грозит исключениями защиты памяти, если на вход подаются строковые литералы. Во-вторых, как уже отмечалось, в ранних версиях Windows NT, LogonUser не позволяет указывать NULL в качестве имени домена, поэтому эта ситуация обрабатывается отдельно и подходящее имя домена находится с помощью функции LookupAccountName.

Таким образом, использование функции LogonUser совсем несложно, однако у нее есть одно очень существенное ограничение в Windows NT и Windows 2000: вызывающий эту функцию пользователь должен иметь привилегию SE_TCB_NAME. Это очень сильная привилегия, настолько сильная, что даже администраторы не имеют этой привилегии. Фактически, эта привилегия означает полный контроль над системой, TCB в ее названии расшифровываются как Trusted Computing Base - часть компьютерной системы, которая обеспечивает выполнение политики безопасности. TCB включает в себя код, исполняющийся в режиме ядра, а также код пользовательского режима, исполняющийся в контексте учетной записи, имеющей TCB-привилегию.

Хотя относительно безопасно предоставить эту привилегию учетной записи, предназначенной для выполнения системной службы, Microsoft не рекомендует так делать, а рекомендует выполнять код, требующий наличия TCB-привилегии, в контексте системной учетной записи. Использование системной учетной записи имеет одно преимущество: у нее нет пароля, следовательно его невозможно украсть или подобрать. Этим вы открываете настолько широкую брешь в безопасности системы, что проверка паролей становится бессмысленной.

Необходимость наличия TCB-привилегии существенно ограничивает область применения данного метода. Хорошая новость заключается в том, что в Windows XP LogonUser более не требует наличия TCB-привилегии у вызывающего потока. Плохая - в том, что количество установленных копий Windows NT и Windows 2000 еще слишком велико, чтобы отказаться от поддержки этих систем. Следующий рассматриваемый нами метод не только не требует никаких привилегий, но и способен работать в операционных Windows 95/98 и Windows Me.

Security Support Provider Interface

SSPI - это вариация стандарта Generic Security Service API (GSS-API) [4,5], реализованная Microsoft. Оба интерфейса предназначены для сетевой аутентификации и защиты информации при передаче по сети. Нас сейчас интересует только аутентификация, сетевой вариант которой отличается от локального тем, что простая передача имени пользователя и пароля неприемлема, так как легко может быть перехвачена злоумышленником. Идея обоих интерфейсов заключается в том, что все протоколы сетевой аутентификации, будь то NTLM, Kerberos или SSL, могут быть представлены в виде упорядоченного обмена сообщениями, в ходе которого одна из сторон аутентифицирует другую (или стороны взаимно аутентифицируют друг друга). Разумеется, SSPI может быть использован и локально, в таком случае передача пакетов по сети заменяется передачей буфера в памяти из одного места программы в другое.

SSPI сам по себе является лишь интерфейсом, предоставляющим стандартизованный доступ к различным пакетам безопасности (security packages). В составе Windows NT 4 поставляется только один пакет безопасности, NTLM, который можно использовать для аутентификации пользователей. Начиная с Windows 2000 в дополнение к пакету NTLM присутствуют пакеты Kerberos и Negotiate. Мы будем использовать NTLM, так как этот пакет доступен во всех версиях Windows NT. Листинг 2 содержит функцию CheckPassword_SSPI, которая проверят правильность имени пользователя и пароля с использованием интерфейса SSPI и пакета безопасности NTLM.

Листинг 2. Проверка имени пользователя и пароля с помощью SSPI


BOOL CheckPassword_SSPI(
IN PCTSTR pszDomainName,
IN PCTSTR pszUserName,
IN PCTSTR pszPassword,
OUT PHANDLE phToken
)
{
_ASSERTE(pszUserName != NULL);
_ASSERTE(pszPassword != NULL);

ULONG lRes;
CSspiClient Client;
CSspiServer Server;

// инициализация клиентского объекта
lRes = Client.Initialize(pszDomainName, pszUserName, pszPassword);
if (lRes != ERROR_SUCCESS)
return SetLastError(lRes), FALSE;

// инициализация серверного объекта
lRes = Server.Initialize();
if (lRes != ERROR_SUCCESS)
return SetLastError(lRes), FALSE;

PVOID pRequest = NULL, pResponse = NULL;
ULONG cbRequest, cbResponse;

// генерация начального запроса на аутентификацию
lRes = Client.Start(&pRequest, &cbRequest);
if (lRes != ERROR_MORE_DATA)
return SetLastError(lRes), FALSE;

// главный цикл обмена сообщениями
for (;;)
{
// обработка клиентского запроса и генерация ответа
lRes = Server.Continue(pRequest, cbRequest,
&pResponse, &cbResponse);
if (lRes != ERROR_MORE_DATA)
break;

Client.FreeBuffer(pRequest);
pRequest = NULL;

// обработка ответа сервера и создание нового запроса
lRes = Client.Continue(pResponse, cbResponse,
&pRequest, &cbRequest);
if (lRes != ERROR_SUCCESS &&
lRes != ERROR_MORE_DATA)
break;

Server.FreeBuffer(pResponse);
pResponse = NULL;
}

if (pRequest != NULL)
Client.FreeBuffer(pRequest);
if (pResponse != NULL)
Server.FreeBuffer(pResponse);

if (lRes != ERROR_SUCCESS)
return SetLastError(lRes), FALSE;

// проверка на гостевую учетную запись
lRes = Server.CheckGuest();
if (lRes != ERROR_SUCCESS)
return SetLastError(lRes), FALSE;

// создание токена, если требуется
if (phToken != NULL)
{
lRes = Server.GetToken(phToken, TOKEN_ALL_ACCESS);
if (lRes != ERROR_SUCCESS)
return SetLastError(lRes), FALSE;
}

return TRUE;
}

В исходном коде функции CheckPassword_SSPI вы не увидите ни одного прямого вызова функции SSPI - все они заключены в классы-обертки СSspiClient и CSspiServer. Это сделано по двум причинам. Во-первых, это позволяет просто и ясно выделить обмен сообщениями между клиентом и сервером, не загромождая код лишними деталями. Во-вторых, это позволит использовать эти классы в качестве основы, если вы решите реализовать сетевую аутентификацию в своем приложении.

Функция начинает свою работу с создания и инициализации объектов клиента и сервера, причем в процессе инициализации объекту клиента передается информация о пользователе. Затем функция вызывает метод CSspiClient::Start, чтобы сгенерировать первоначальный запрос на аутентификацию, после чего переходит в цикл обмена сообщениями. В цикле функция вызывает методы CSspiServer::Continue и CSspiClient::Continue. Эти методы принимают на вход сообщение противоположной стороны и возвращают ответное сообщение. Цикл обмена сообщениями происходит до тех пор, пока сервер не решит, что аутентификация успешна, или наоборот, неуспешна.

Аутентификации NTLM в Windows NT 4.0 и Windows 2000 присуща одна особенность. Если (1) клиент пытается войти в систему с именем, неизвестным ни на локальной системе, ни в доверенных доменах, и при этом (2) в локальной системе не заблокирована гостевая учетная запись, то аутентификация завершается успешно, а клиент входит в систему под именем локальной гостевой учетной записи. Это поведение реализовано для совместимости со старым продуктом Microsoft под названием NT Lan Manager (от которого протокол NTLM и получил свое название), но нам оно совсем не нужно. Поэтому после успешной аутентификации пользователя вызывается метод CSspiServer::CheckGuest, который проверяет, не был ли успех аутентификации результатом описанной особенности.

Наконец, если требуется выдать токен пользователя, функция вызывает метод CSspiServer::GetToken, который отвечает за получение токена.

Я позволю себе не рассматривать реализацию классов CSspiClient и CSspiServer, так как это требует подробного знакомства с интерфейсом SSPI, что в свою очередь заслуживает отдельной большой статьи [3]. Вы можете найти реализацию этих классов в файлах sspiauth.h и sspiauth.cpp исходного кода демонстрационного приложения.

Важным преимуществом данного метода перед методом с использованием функции LogonUser является то, что для его использования не требуются никаких привилегий. Недостаток состоит в том, что этот метод всегда реализует сетевой вход в систему (LOGON32_LOGON_NETWORK), что не всегда приемлемо (но если мы хотим всего лишь проверить правильность имени и пароля пользователя, это не имеет значения).

Завершая рассмотрение этого метода, хочу заметить, что данный метод аутентификации может быть использован и в среде операционных систем Windows 95/98 и Windows Me, но только для проверки подлинности доменных учетных записей. Для этого система должна быть сконфигурирована для безопасности на уровне пользователей (user-level security) и должен быть указан сервер Windows NT, на который будут пересылаться запросы на аутентификацию.

Функция NetUserChangePassword
"Для каждой сложной проблемы существует решение, которое является 
простым, ясным... и неправильным". -- Генри Луис Менкен

В форумах и группах новостей можно встретить рекомендацию проверять правильность имени пользователя и пароля с помощью функции NetUserChangePassword, установив при вызове этой функции новый пароль идентичным старому.


NET_API_STATUS NetUserChangePassword(
PCWSTR domainname, // имя домена
PCWSTR username, // имя пользователя
PCWSTR oldpassword, // старый пароль
PCWSTR newpassword // новый пароль
);

Просто, не правда ли? Не поддавайтесь искушению. Проблема этого метода состоит в том, что он не всегда работает. Во-первых, пользователю может быть запрещено изменять пароль или политика безопасности не позволяет использовать новый пароль идентичный старому. Эту проблему можно попытаться обойти, анализируя код ошибки, возвращаемый функцией NetUserChangePassword, листинг 3 иллюстрирует этот подход:

Листинг 3. Использование функции NetUserChangePassword


BOOL CheckPassword_ChangePwd(
IN PCTSTR pszDomainName,
IN PCTSTR pszUserName,
IN PCTSTR pszPassword,
OUT PHANDLE phToken
)
{
_ASSERTE(pszUserName != NULL);
_ASSERTE(pszPassword != NULL);
_ASSERTE(phToken == NULL);

USES_CONVERSION;

PCWSTR pszPasswordW = T2CW(pszPassword);

ULONG lRes = NetUserChangePassword(T2CW(pszDomainName),
T2CW(pszUserName), pszPasswordW, pszPasswordW);

if (lRes == ERROR_INVALID_PASSWORD ||
lRes == NERR_UserNotFound)
return SetLastError(ERROR_LOGON_FAILURE), FALSE;

return TRUE;
}

Код функции CheckPassword_ChangePwd полагается на то, что NetUserChangePassword всегда возвращает точные коды ошибок. Но даже в таком варианте функция не будет работать правильно в Windows NT 4.0, если политика безопасности требует, чтобы пользователь осуществил вход в систему перед изменением пароля (этот параметр политики безопасности удален в Windows 2000). В этом случае функцию NetUserChangePassword может вызвать только администратор или сам пользователь, чей пароль требуется изменить. При попытке вызвать эту функцию от лица другого пользователя, она вернет ошибку, по которой уже нельзя будет сказать, был пароль правильным или нет.

Наконец, главная проблема этого метода, что функция NetUserChangePassword не предназначена для аутентификации пользователей. Использование ее не по назначению может принести проблемы с той стороны, с которой вы даже не ожидаете. Например, вызовы этой функции приводят к появлению в журнале аудита совсем не тех записей, которые обычно появляются при входе пользователей в систему. Большие организации часто используют специальные программы, анализирующие журнал аудита и выдающие предупреждение при обнаружении подозрительных событий. Теперь представьте себе, что такая система обнаружила слишком частые (и неудачные, так как повторяющиеся пароли запрещены в этой организации политикой безопасности!) попытки сменить пароль. Система бъет тревогу и отправляет многочисленные сообщения на пейджер администратору, который в это время кушает свой ланч. Вас можно только пожалеть, если вы окажетесь неподалеку.

Заключение

Итак, мы рассмотрели два метода аутентификации пользователей и один метод "аутентификации". Метод с использованием функции LogonUser весьма прост, но требует наличия TCB-привилегии у вызывающего. Метод с использованием SSPI не требует никаких привилегий, но достаточно сложен в реализации. Наконец, метод c использованием функции NetUserChangePassword не является методом аутентификации вовсе. Какой из них выбрать, во многом зависит от поставленной задачи. Надеюсь, прочитав эту статью, вы сможете сделать правильное решение.

Демонстрационное приложение

Ссылки
  1. Keith Brown, Programming Windows Security. Addisson-Wesley, 2000.
  2. HOWTO: Validate User Credentials on Microsoft WinNT and Win95, Q180548, Microsoft Knowledge Base.
  3. Keith Brown, Explore the Security Support Provider Interface Using the SSPI Workbench Utility, MSDN Magazine, August 2000.
  4. J. Linn, Generic Security Service Application Program Interface, RFC 1508, Geer Zolot Associate, September 1993.
  5. J. Wray, Generic Security Service API : C-bindings, RFC 1509, Digital Equipment Corporation, September 1993.