Аутентификация c использованием внешней cookie

Требования

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

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

Стратегия реализации

Код вики должен быть изменён в двух местах: в wikiconfig.py должен быть добавлен новый класс ExternalCookie, который будет использоваться для аутентификации пользователя. При аутентификации в веб-приложении будет фактически выполнена аутентификация на вики. При завершении сессии в приложении также перестаёт быть доступна аутентификация на вики. Ряд переменных добавляется в конфигурацию экземпляра вики для изменения страницы «Einstellungen: Einstellungen» и для автоматического создания учётных записей при необходимости.

Темы экземпляра вики могут быть модифицированы для изменения метода username класса Theme. Этот метод генерирует ссылки «Anmelden» и «Abmelden» в области навигации страницы вики. Эти ссылки должны быть модифицированы для ссылания на страницы аутентификации и завершения сессии в веб-приложении.

Также необходимо добавить в веб-приложение механизмы создания/удаления cookie и (опционально) создания/удаления записей о сессиях в хранилище, в случае, если они отсутствуют. Если пользователи имеют доступ к вики без аутентификации, хорошей практикой является добавление перенаправления на referrer (страницу, с которой был осуществлён переход) страницы аутентификации в случае успешной аутентификации (аналогично тому, как ведёт себя процесс аутентификации в МойнМойн).

Нереализованные варианты

Синхронизация возможных времён неактивности сессии между приложением и экземпляром не является предпочтительной. Если сессия пользователя инвалидировалась после часа отсутствия активности, аутентификационная cookie может позволять редактировать и сохранять страницы на вики, только если веб-приложение не удаляет записи о просроченных сессиях из общего хранилища. Если же пользователь с просроченной сессией аутентифицируется вновь, то будет создана новая запись в общем хранилище и сгенерирована новая cookie MoinAuth — последующие операции на вики будут использовать новую cookie. Приведённый в качестве примера вариант файла wikiconfig.py содержит пример того, как просроченные записи могут удаляться из таблицы !MySQL после истечения их срока жизни при выполнении операции на вики. В то же время, при развёртывании большинства экземпляров вики скорее всего будет принято решение удалять неактуальные записи в хранилище силами веб-приложения.

Альтернативой является использование общего хранилища для шифрования cookie в веб-приложении и расшифровке её в методе external_auth. Если реализуется данный вариант, добавление временной метки к cookie приведёт к инвалидации просроченных cookie при проверки их времени жизни. Без этой проверки, любая сгенерированная cookie будет являться валидной сколь угодно долгий период времени до тех пор, пока не изменится метод шифрования. Тесты с !exPyCrypto показали, что время, затрачиваемое на расшифровку больше, чем проверка с использованием базы !MySQL: в случае использования базы данных время валидации cookie занимало обычно 0 секунд и не превышало значения в 0,02 секунды.

Ещё одним способом фильтровать украденные cookie является добавление пользовательского IP-адреса к cookie и сравнение его с IP-адресом, с которого происходит запрос к МойнМойн. Но данный способ не является надёжным ввиду динамического назначения адресов некоторыми ISP и, как следствие необходимости постоянной повторной аутентификации, равно как использование reverse proxy/NAT приводит к тому, что множество пользователей приходят с одного адреса.

Класс ExternalCookie

Первым шагом является переопределение метода external_auth класса Config путём добавления примерно следующего кода в конфигурацию вики:

Код, представленный ниже, предназначен для МойнМойн версии 1.9.

   1 # +++++++++++++++ начало примера external_cookie
   2 
   3 # Данный пример кода может быть полезен при реализации аутентификации с
   4 # использованием внешней cookie (созданной внешней программой, не МойнМойн)
   5 # c МойнМойн. См. места, помеченные +++, для изменения согласно потребностям.
   6 # Данный код необходимсо скопировать в файл farmconfig.py или wikiconfig.py,
   7 # заменяя имеющуюся строку
   8 #
   9 #    class Config(DefaultConfig):
  10 # или
  11 #    class FarmConfig(DefaultConfig):
  12 
  13 
  14 from MoinMoin.config.multiconfig import DefaultConfig
  15 from MoinMoin.auth import BaseAuth
  16     
  17 #  Данная функция добавлена в случае, если понадобится журналирование действий
  18 #  во время тестирования 
  19 import time
  20 def writeLog(*args): 
  21     '''Write an entry in a log file with a timestamp and all of the args.'''
  22     s = time.strftime('%Y-%m-%d %H:%M:%S ',time.localtime())
  23     for a in args:
  24         s = u'%s %s;' % (s,a)
  25     log = open('/somelogfile', 'a') # +++ расположение файла с журналом событий
  26     log.write('\n' + s + '\n')
  27     log.close()
  28     return
  29 
  30 # Представленные ниже два метода являются примером того, как можно
  31 # аутентифицировать по cookie другого приложения.
  32 import MySQLdb
  33 def verifySession(sidHash): # +++ для использования данного метода необходимо
  34                             #     раскомментировать его вызов ниже в
  35                             #     ExternalCookie.
  36     """Return True if sidHash value exists (meaning user is currently logged on), false otherwise.  
  37     
  38     If you are not a MySQL user, find another way to store this information.  ActiveSession is a two-column table
  39     containing a hashed cookie value (sidHash) plus a date-time stamp (tStamp).
  40     Your other application must add an entry to this table each time a user logs on and delete the entry when the user logs off.
  41     """
  42     db = MySQLdb.connect(db='mydb',user='myid',passwd='mypw') #+++ пользователь должен иметь доступ на чтение
  43     c = db.cursor()
  44     q = 'select sidHash from ActiveSession where sidHash="%s"' % sidHash
  45     result = c.execute(q)
  46     c.close()
  47     if result == 1:
  48         return True
  49     return False
  50     
  51 def verifySessionPlus(sidHash,timeout=3600*4): # +++ для использования данного
  52                                                #     метода необходимо
  53                                                #     раскомментировать его
  54                                                #     вызов ниже в
  55                                                #     ExternalCookie.
  56     """Return True if sidHash value exists (meaning user is currently logged on), false otherwise.  
  57     
  58     This version of verifySession deletes entries inactive for more than 4 hours and
  59     updates the tStamp field with each moin transaction.  If performance is 
  60     important, find another way to delete inactive sessions.
  61     """
  62     db = MySQLdb.connect(db='mydb',user='myid',passwd='mypw') # +++ пользователь должен иметь доступ на запись
  63     c = db.cursor()
  64     q = 'delete from ActiveSession where tStamp<"%s"' % int(time.time() - timeout) # удаление неактивных записей
  65     result = c.execute(q)
  66     q = 'update ActiveSession set tStamp=%s where sidHash="%s"' % (int(time.time()),sidHash)
  67     result = c.execute(q)
  68     c.close()
  69     if result == 1:
  70         return True
  71     return False
  72     
  73 
  74 class ExternalCookie(BaseAuth):
  75     name = 'external_cookie'
  76     # +++ Следующие две строки могут быть полезны в случае, если
  77     #     переопределяется метод username в используемых на вики темах.
  78     #     В случае, если они закомментированы, страницы вики не будут содержать
  79     #     ссылок на аутентификацию или завершение сессии.
  80     login_inputs = ['username', 'password'] # +++ необходимо для показа ссылки
  81                                             #     на страницу аутентификации
  82                                             #     в области навигации страниц
  83     # logout_possible = True # +++ необходимо для показа ссылки на завершение
  84                              #     сессии в области навигации вики-страниц.
  85     
  86     def __init__(self, autocreate=False):
  87         self.autocreate = autocreate
  88         BaseAuth.__init__(self)
  89 
  90     def request(self, request, user_obj, **kw):
  91         """Return (user-obj,False) if user is authenticated, else return (None,True). """
  92         # login = kw.get('login') # +++ пример не использует данную переменную;
  93                                   #     предполагается, что аутентификация
  94                                   #     выполняется во внешнем приложении
  95         # user_obj = kw.get('user_obj')  # +++ пример не использует данную переменную
  96         # username = kw.get('name') # +++ пример не использует данную переменную
  97         # logout = kw.get('logout') # +++ пример не использует данную переменную;
  98                                     #     предполагается, что завершение сессии
  99                                     #     выполняется во внешнем приложении
 100         import Cookie
 101         user = None  # пользователь не аутентифицирован
 102         try_next = True  # если значение равно True, МойнМойн попытается
 103                          # воспользоваться следующем способом аутентификации
 104                          # в списке доступных способов аутентификации.
 105         
 106         otherAppCookie = "MoinAuth" # +++ имя пользователя, почтовый адрес, псевдоним
 107                                     #     пользователя и идентификатор сессии
 108                                     #     разделяются символом "#"
 109         try:
 110             cookie = Cookie.SimpleCookie(request.cookies) # появилось в МойнМойн 1.9
 111         except Cookie.CookieError:
 112             cookie = None # игнорировать невалидные cookie
 113 
 114         if cookie and otherAppCookie in cookie: # наличие данного cookie означает,
 115                                                 # что аутентификация пользователя
 116                                                 # уже выполнена во внешнем приложении
 117             import urllib
 118             if sys.version_info[:2] > (2, 5):
 119                 cookievalue = cookie[otherAppCookie].value # МойнМойн 1.9 и Python 2.6
 120             else:
 121                 cookievalue = cookie[otherAppCookie] # МойнМойн 1.9 и Python 2.5
 122             # writeLog('cookievalue',cookievalue)
 123             # +++ декодирование и обработка значения cookie - необходимо отредактировать
 124             #     сообразно потребностям.
 125             #~ cookievalue = urllib.unquote(cookievalue) # значение cookie является urlencoded,
 126                                                          # необходимо раскодировать его (МойнМойн 1.9)
 127             #~ cookievalue = cookievalue.decode('utf-8') # декодирование кодировки cookie в Unicode (МойнМойн 1.9)
 128             cookievalue = cookievalue[1: -1] # удаления кавычек в начале и в конце (МойнМойн 1.9)
 129             # writeLog(u'значение cookie',cookievalue)
 130             cookievalues = cookievalue.split('#') # cookie имеет вид имя_пользователя#почтовый_адрес#псевдоним#идентификатор_сессии
 131 
 132             email = aliasname = sessionid = ''
 133             try:  # извлечение полей из cookie внешнего приложения
 134                 auth_username = cookievalues[0] # имя пользователя на вики
 135                 email = cookievalues[1] # почтовый адрес необходим пользователю для
 136                                         # изменения и сохранения пользовательских предпочтений
 137                 aliasname = cookievalues[2] # псевдноим актуален только в случае,
 138                                             # если имя пользователя неудобно для
 139                                             # использования на вики
 140                 sessionid = cookievalues[3] # опциональный идентификатор сессии
 141                                             # внешнего приложения - уникальная
 142                                             # временная метка или случайное число 
 143             except IndexError: 
 144                 pass  # псевдоним и идентификатор сессии не нужны, кроме случая,
 145                       # если раскомментированы соответствующие строки ниже
 146             # writeLog(u'имя пользователя',auth_username)
 147             # writeLog(u'почтовый адрес',email)
 148             # writeLog(u'псевдоним',aliasname)
 149             # writeLog(u'идентификатор сессии',sessionid)
 150 
 151             # +++ так как кто угодно может сгенерировать cookie, далее представлена
 152             #     проверка, что cookie создана именно внешним приложением
 153             if auth_username:
 154                 import hashlib  # +++ данная библиотека появилась в Python 2.5+;
 155                                 #     см. http://code.krypto.org/python/hashlib
 156                                 #     для версий Python 2.3, 2.4
 157                 sidHash = hashlib.md5(cookievalue).hexdigest() # МойнМойн 1.9
 158                 # writeLog('sidHash',sidHash)
 159                 sidOK = verifySession(sidHash) # проверка, что пользователь аутентифицирован
 160                                                # во внешнем приложении
 161                                                # +++ или же использовать verifySessionPlus
 162                 if not sidOK:
 163                     auth_username = None
 164             # writeLog(u'имя пользователя после verifySession',auth_username)
 165             
 166             if auth_username:
 167                 # пользователь аутентифицирован, необходимо создать объект, описывающий пользователя МойнМойн
 168                 from MoinMoin.user import User
 169                 # передача auth_username в конструктор User означает, что аутентификация уже выполнена.
 170                 user = User(request, name=auth_username, auth_username=auth_username, auth_method=self.name)
 171                 changed = False
 172                 if email != user.email: # обновлялся ли почтовый адрес?
 173                     user.email = email ; 
 174                     changed = True # если да, необходимо обновить профиль
 175                 # if aliasname != user.aliasname: # +++ обновлялся ли псевдоним?
 176                     # user.aliasname = aliasname ; 
 177                     # changed = True # если да, необходимо обновить профиль
 178 
 179                 if user:
 180                     user.create_or_update(changed)
 181                 if user and user.valid: 
 182                     try_next = False # есть валидный пользователь; список доступных
 183                                      # методов аутентификации нет нужды более обрабатывать
 184         # writeLog(str(user), try_next)
 185         return user, try_next 
 186 
 187 from MoinMoin.config import multiconfig, url_prefix_static
 188 
 189 class Config(multiconfig.DefaultConfig):
 190 # class FarmConfig(multiconfig.DefaultConfig):
 191 
 192     auth = [ExternalCookie(autocreate=True)] # аутентификация по внешней cookie является
 193                                              # единственным способом аутентификации
 194                                              # параметр autocreate появился в версии 1.8.0
 195     
 196     # +++ Ниже представлены рекомендуемые изменения в форме пользовательских предпочтений
 197     #     в случае, если external_cookie является единственным способом аутентификации
 198     user_form_disable = ['name', 'aliasname', 'email',] # показывать, но не давать изменять
 199     user_form_remove = ['password', 'password2', 'css_url', 'logout', 'create', 'account_sendmail','jid'] # удалить полностью
 200     #~ user_autocreate = True # +++ МойнМойн будет создавать учётный записи автоматически при их отсутствии
 201     cookie_lifetime = (12, 12)   # (анонимная сессия, аутентифицированная сессия) по умолчанию равно (0,12) в МойнМойн 1.9
 202 
 203 # +++++++++++++++ конец примера реализации external_cookie, далее следует остальная конфигурация экземпляра вики 

Начальное тестирование

В случае наличие Firefox с расширением Web Developer, начальное тестирование можно осуществить довольно быстро. Достаточно аутентифицироваться в веб-приложении и кликнуть в Tools — Web Developer — Cookies — View Cookie Information. Скорее всего, можно будет обнаружить как минимум одну cookie, которая выглядит как идентификатор сессии, созданный веб-приложением во время аутентификации, обычно она содержит большое случайное число и, вероятно, временную метку.

Далее, необходимо открыть Tools — Web Developer — Cookies — Add Cookie. Дать cookie имя «MoinAuth» (с сохранением регистра). Указать в качестве значения «имя_пользователя#почтовый_адрес» без пробелов и с использованием «#» в качестве разделителя. Если веб-приложение и экземпляр вики находятся на одном поддомене, задать FQDN тем же, что использует веб-приложение (если нет, не указывать поддомен в доменном имени для того, чтобы cookie была доступна со всех поддоменов домена). Установить путь cookie в «/». Установить переключатель «Session Cookie» и сохранить cookie.

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

Изменения в веб-приложении

Приведённый в качестве примера метод external_cookie ожидает, что cookie будет содержать следующую информацию, разделённую символом «#»:

Веб-приложение должно сохранять cookie с именем «MoinAuth» и путём «/». Обычно устанавливать путь в «/» не рекомендуется, так как это несёт с собой определённые риски по безопасности, так как оно позволяет приложениям под этим путём читать содержимое cookie. В данном случае это именно то, что необходимо — MoinMoin должен иметь возможность прочитать cookie, установленные другим приложением.

Как было показано ранее, можно легко создать cookie с необходимой информацией. Для предотвращения подобных случаев МойнМойн должен проверять валидность cookie путём поиска соответствующей записи в защищённом хранилище, созданном веб-приложением.

По возможности необходимо избежать создания дополнительных проблем с боезопасностью посредством создания связи имён пользователей и идентификаторов сессий или даже идентификаторов сессий, которые могут быть получены и использованы. Лучшим решением является хэширование cookie «MoinAuth» и сохранение результата в защищённом хранилище. Добавление временной метки к каждой записи позволит удалять просровенные записи в дальнейшем.

В примере файла wikiconfig.py присутствует неиспользуемый код (метод verifySession), который выполняет хэширование содержимого cookie и проверку наличия результата в таблице MySQL. Кроме того, имеется альтернативный код (метод verifySessionPlus), который удаляет из таблицы записи старше 4 часов, проверяет хешированную cookie и обновляет временную метку. Лучшим способом в данном случае является модификация вбе-приложения для удаления устаревших записей из таблицы, возможно, при каждой аутентификации пользователя.

Как только модификация веб-приложения будет начата, практически всё тестирование можно выполнять посредством расширения Firefox Web Developer. Перед аутентификацией в приложении cookie MoinAuth должна отсутствовать, после успешной аутентификации она должна появляться, и исчезать при завершении сессии. Кроме того, защищённое хранилище должно пополняться хэшированными значениями cookie после аутентификации и очищаться от более неактуальных во время завершения сессии. Доступ к страницам вики при наличии аутентифицированной сессии должен приводить к создании cookie «MOIN_SESSION».

Модификация тем

Следующим шагом являетсямодификация ссылок на выполнение аутентификации и завершения сессии в навигационной области вики-страниц. Если пользователи вики могут использовать только одну тему, необходимо модифицировать код ниже для указания на страницы аутентификации/завершения сессии в приложении. Если же пользователи могут выбирать тему, возможно, проще всего будет модифицировать непосредственно файл MoinMoin/theme/__init__.py.

Если аутентификация происходит строго перед доступом к вики-страницам, эта модификация, равно как и следующая, могут не выполняться. Если закомментировать переменные login_inputs и logout_possible в начале класса ExternalCookie, вики-страницы не будут содержать ссылки на аутентификацию и завершение сессии.

Код, представленный ниже, предназначен для МойнМойн версии 1.9.

   1     def username(self, d):
   2         """ Assemble the username / userprefs link
   3         
   4         @param d: parameter dictionary
   5         @rtype: unicode
   6         @return: username html
   7         """
   8         request = self.request
   9         _ = request.getText
  10 
  11         userlinks = []
  12         # Добавить ссылку на домашнюю страницы пользователя для
  13         # зарегистрированных пользователей. Нет нужды проверять её
  14         # существование, пользователь может создать её.
  15         if request.user.valid and request.user.name:
  16             interwiki = wikiutil.getInterwikiHomePage(request)
  17             name = request.user.name
  18             aliasname = request.user.aliasname
  19             if not aliasname:
  20                 aliasname = name
  21             title = "%s @ %s" % (aliasname, interwiki[0])
  22             # ссылка на (внешнюю) домашнюю страницу пользователя
  23             homelink = (request.formatter.interwikilink(1, title=title, id="userhome", generated=True, *interwiki) +
  24                         request.formatter.text(name) +
  25                         request.formatter.interwikilink(0, title=title, id="userhome", *interwiki))
  26             userlinks.append(homelink)
  27             # ссылка на действие userprefs
  28             if 'userprefs' not in self.request.cfg.actions_excluded:
  29                 userlinks.append(d['page'].link_to(request, text=_('Settings'),
  30                                                querystr={'action': 'userprefs'}, id='userprefs', rel='nofollow'))
  31 
  32         if request.user.valid:  
  33             if request.user.auth_method in request.cfg.auth_can_logout:
  34                 userlinks.append('<a href="/myapp/Logout">%s</a>' % _('Logout', formatted=False)) # +++ страница завершения сессии во внешнем приложении
  35         else:
  36             query = {'action': 'login'}
  37             # Специальная ссылка напрямую на аутентификацию если методы аутентификации не требуют ввода.
  38             if request.cfg.auth_login_inputs == ['special_no_input']:
  39                 query['login'] = '1'
  40             if request.cfg.auth_have_login:
  41                 userlinks.append('<a  href="/myapp/Login">%s</a>' % _("Login", formatted=False)) # +++ страница аутентификации во внешнем приложении
  42 
  43         userlinks = [u'<li>%s</li>' % link for link in userlinks]
  44         html = u'<ul id="username">%s</ul>' % ''.join(userlinks)
  45         return html

Модификация страницы аутентификации веб-приложения

На последнем этапе можно также рассмотреть необходимость модификации страницы аутентификации для пользователей вики, решивших перейти на страницу аутентификации со страницы вики. Большинство веб-серверов передают приложению referrer запроса, содержащий страницы, с которой был осуществлён запрос данной.

Можно рповерить его значение для определения, яляется ли referrer страницей вики и, если да, сохранить URL и перенаправить на него в случае успешной аутентификации.