В данной статье хочу рассказать про реализацию системы единого входа на форумы “Сети Знаний”.
Входные данные. Имеется система форумов вопросов и ответов, движок которых написан на Python. Каждый форум — это отдельное веб-приложение со своей базой данных. Все форумы работают из одних исходников.
Задача. Реализовать возможность входа пользователей на форумы, на которых они еще не зарегистрированы, по имеющимся данным с другого форума.
Первое приближение.
Когда о задачи было сказано впервые, хотелось сделать все без дополнительной базы данных и форм авторизации. То есть у на сеть форма входа:
Для того, чтобы войти зарегистрированному пользователю, достаточно вести ник и пароль, либо воспользоваться OpenID провайдером. Хотелось использовать схему, когда пользователь вводит данные с любого форума в эти поля. В этом случае в коде движка мы перехватываем данные формы, и если пользователь не зарегистрирован, запрашиваем данные со всех форумов динамически. Впоследствии мы отказались от данного подхода.
Текущее решение.
В сегодняшнем решении используется другой подход — использование общей базы данных. Для входа пользователю необходимо использовать дополнительную форму:
Сложности:
- слияние баз данных всех сайтов воедино;
- поддержание актуальности единой базы;
- синхронизация профилей;
- дублирование данных.
Для идентификации участника на стороне форума я добавил новое поле в табличку пользователей — SeznID
. В него мы будем записывать ID пользователя из единой базы.
В рамках одного форума уникальным является ник (имя пользователя), но на разных форумах могут быть одинаковые ники, которые принадлежат разным участникам (на дынный момент в “Сети Знаний” зарегистрировано много пользователей имеющих аккаунты практически на всех 12 форумах).
Необходимо создать единую базу данных таким образом, чтобы пользователю принадлежали только его профили.
Для слияния баз форумов с sezn.ru уходит запрос на все форумы по очереди на получение пользователей. Для каждого участника мы проверяем, есть ли он в базе, и затем отправляем обратно на форум его ID.
func load_forum_users(forumUrl string) {
usersCount := get_users_count(forumUrl, false)
for i := 0; i < usersCount; i += UsersCountPerRequest {
users := get_forum_users(forumUrl, i, UsersCountPerRequest, false)
for _, user := range users {
forumId := user.Id
exist, selectedUser := mmgr.ModelLoader.IsExist(user)
if !exist {
if err := add_new_froum_user(user); err == nil {
setup_forum_user_profile (user.Id, forumId, forumUrl)
user.Sync(forumUrl)
}
} else {
setup_forum_user_profile (selectedUser.Id, forumId, forumUrl)
}
}
}
}
На стороне форума обработка запроса на добавление SeznID выглядит примерно так:
def setup_user_sezn_id(request, uid, sid):
try:
user = User.objects.get(id=uid)
user.sid = sid
user.save()
json = simplejson.dumps({"Result": "Success"})
except:
json = simplejson.dumps({"Error": "Invalid sezn id or user id"})
return HttpResponse(json, mimetype='application/json')
При регистрации нового пользователя, любой форум отправляет запрос на sezn.ru. В ответе приходит SeznID
, который будет присвоен пользователю.
Код на стороен sezn.ru
func forumUserRegisterHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
regData := get_register_data(w, r)
user, err := create_forum_user(regData)
if err != nil || user == nil {
b, _ := json.Marshal(JsonErrorResponse{"Invalid input data"})
w.Write(b)
return
}
b, _ := json.Marshal(user.UserModel)
w.Write(b)
}
В единой базе данные хранятся в такой же форме, как и на форумах. Интерес представляет способ аутентификации. Когда пользователь вводит пароль и ник, на форуме работает стандартный механизм Django: мы находим пользователя по нику и проверяем введенный и имеющийся пароль.
def check_password(raw_password, enc_password):
algo, salt, hsh = enc_password.split('$')
return hsh == get_hexdigest(algo, salt, raw_password)
Функция get_hexdigest
используется и при создании пароля.
def get_hexdigest(algorithm, salt, raw_password):
raw_password, salt = smart_str(raw_password), smart_str(salt)
if algorithm == 'crypt':
try:
import crypt
except ImportError:
raise ValueError('"crypt" password algorithm not supported in this environment')
return crypt.crypt(raw_password, salt)
if algorithm == 'md5':
return md5_constructor(salt + raw_password).hexdigest()
elif algorithm == 'sha1':
return sha_constructor(salt + raw_password).hexdigest()
raise ValueError("Got unknown password algorithm type in password.")
В Go я сделал аналогичное. Функция авторизации будет выглядеть так:
func user_by_email_and_raw_pass(email, pass string) (*User, error) {
user, err := mmgr.ModelLoader.LoadUserByEmail(email)
if user == nil || err != nil {
return nil, err
}
if err = check_password(&user.UserModel, pass); err == nil {
return &user, nil
}
return nil, errors.New(“User does not exist.”)
}
Функция check_password
:
func check_password(model *UserModel, rawPassword string) error {
pass := strings.Split(model.Password, Divider)
if len(pass) != 3 {
return errors.New("Invalid format”)
}
hash := SHA1Passwd(pass[0], pass[1], rawPassword)
if hash != model.Password {
return errors.New("Invalid password")
}
return nil
}
Функция SHA1Passwd
:
func SHA1Passwd(alg, salt, rawPassword string) string {
h := sha1.New()
io.WriteString(h, salt+rawPassword)
return fmt.Sprintf("%s$%s$%x", alg, salt, h.Sum(nil))
}
В процессе пользователи могут изменять данные. Для нас самым главным является ник, почта, пароль и OpenID провайдеры. При изменении этих данных на одном форуме, мы будем изменять данные в единой базе, а также на других форумах. Тонкость в том, что инициатор запроса не отправляет данные с сервера на сервер. Процесс выглядит так:
Весь остальной API для обмена данными построен на таком же принципе. В общем, мы добавили перехваты:
- регистрации;
- входа на форум;
- изменение профиля пользователя;
- изменение пароля;
- добавление/удаление OpenID провайдеров.
На этом хочу закончить данную статью. Далее секция Q&A.
Пожалуйста, задавайте ваши вопросы в комментариях к посту, на форуме ХэшКод c метками “Go” и “Python” или в личных сообщениях. Лучшие вопросы я буду рассматривать в конце каждой статьи.
1. Пользователь antarx в комментарии к предыдущему посту рекомендовал использовать перегрузку функций в сочетании с наследованием для избежания разрастания кода запросов к базе.
На самом деле, сильно делу такой подход не поможет. Представим, что у нас есть функция запроса объектов из базы по ID
пользователя — GetQuestionByUserId(id int)
. Проблема в том, что если мы захотим выбрать вопросы по пользователю до какой-то даты, то надо писать еще одну функцию — GetQuestionByUserIdAndDate(id int, date time.Time)
. Затем мы придумаем еще одну сверх крутую выборку по пользователю, дате и голосам за — GetQuestionByUserIdAndDateAndScore(id int, date time.Time, score int)
. В будущем мы захотим делать выборку по дате и очкам для все пользователей. И так до бесконечности. В случае с предложенным подходом у нас будут дублироваться структуры. Кода в этом случае будет еще больше. Выходом будет создание структуры запроса. Например,
type QuestionQuery struct {
UserId int
Date time.Time
Score int
// И так далее
}
Вся логика запроса переносится в GetQuestion(query QuestionQuery)
. Минус — если написать логику функции плохо, производительность может сильно просесть.
2. Следующий вопрос от того же пользователя antarx про использование паник как исключений.
Да, их можно так использовать, но, как мне кажется, это не верно. Обрабатывать паники еще сложнее, чем возвращающие значения. Напомню, для обработки паники следует написать примерно такой код:
func myFunc() {
defer func() {
if x := recover(); x != nil {
Logger.Printf("run time panic: %v", x)
}
}()
DoSomeAction()
DoSomeOtherAction()
}
Получается, что мы не можем обернуть вызов одной функции в обработчик, так как панику мы отловим лишь в конце вызывающей функции!
3. Пользователь sectronix в статье про работу с базой писал, что изложение слишком сумбурное.
Это связанно с тем, что исходная статья обычно раза в 3 больше. Перед публикаций я ее урезаю до 6-8 тысяч знаков. Как мне кажется, статьи большего размера воспринимаются очень тяжело. Решением является секция вопросов и ответов. Пожалуйста, спрашивайте меня обо всем, в том числе о том, что плохо описано.
4. Пользователь jetman упомянул про большой проект размером 110 тыс строк. Я решил посмотреть сколько строк у нас. Для этого написал простой скрипт:
#!/bin/bash
countWords=0
for file in ./*.go
do
num=`wc -l $file | cut -d ' ' -f 1`;
countWords=`expr $countWords + $num`
done
echo $countWords
Результат:
- careers.hashcode.ru/ — 33378
- sezn.ru/ — 4241
Автор: DevExpert