введение
Во-первых, почему бы вам не заняться бизнесом?Потому что наша компания - это только я как фронтенд, поэтому я послушно не пишу, какой SSO на странице. Причина, по которой я подумал о написании единого входа SSO, заключается в том, что я обнаружил, что логин компании очень запутан, каждая система является независимым логином, а некоторые предприятия пересекаются, так как одно из них а, а xxx.com — б. xxx.com, так почему бы не унифицировать логин... как раз к тому моменту, когда наш старший брат по бэкенду решил техническую проблему, поэтому я начал писать один пункт, ожидая входа в интерфейс.
С точки зрения стека технологий, серверная часть принимаетNodeJSДля реализации используйте локальную сессиюexpress-sessionподдерживать,sessionиспользуемого хранилищаredis, так как текущие проекты отделены от фронтенда и бекенда, чтобы больше соответствовать текущей бизнес-логике, обычный переход кpassportЧасть входа на сервер аутентификации преобразуется в интерфейс, так что этоSSOбольше подходит для использования вSPAсередина.
Далее будет конкретно описана реализация и обобщены некоторые моменты, требующие внимания.Я надеюсь, что следующие скромные мнения могут быть полезны для всех.
Принцип реализации
SSO расшифровывается как Single Sign On, что означает, что когда вы входите в одну систему в многосистемной группе приложений, вы можете авторизоваться во всех других системах без повторного входа в систему. Для SSO обычно требуется независимый центр аутентификации (паспорт). Вход в подсистемы должен проходить через паспорт. Сама подсистема не будет участвовать в операции входа. При успешном входе в систему паспорт выдает токен для каждой подсистемы. , Подсистемы могут получать свои собственные защищенные ресурсы с помощью токенов.Чтобы уменьшить частую аутентификацию, каждая подсистема будет устанавливать локальный сеанс после авторизации по паспорту, и нет необходимости повторно инициировать аутентификацию по паспорту в течение определенного периода времени.
Как показано на рисунке, это относительно распространенная реализация единого входа.Изображение взято из
На приведенном выше рисунке подробно описан процесс запроса ресурсов для SSO. Но здесь есть момент, который не подходит для моего текущего бизнес-сценария, то есть я не хочу прыгать в центр аутентификации при входе в систему, поэтому я преобразовал эту часть в интерфейс для реализации, а остальные реализации в основном так же, как показано на рисунке. .Реализация
Подготовьте среду
Прежде всего, необходимо провести некоторые подготовительные работы, для тестирования SSO требуется как минимум три доменных имени, здесь я моделирую непосредственно локально. Если у вас под рукой есть доменное имя сервера, то этот шаг можно естественно пропустить.
Создание локального доменного имени (Mac)
1. Настройте файл hosts
// MacOS
sudo vim /etc/hosts
// 添加以下三行
127.0.0.1 testssoa.xxx.com
127.0.0.1 testssob.xxx.com
127.0.0.1 passport.xxx.com
2. Добавьте конфигурацию обратного прокси nginx
- Сначала установите nginx
- Добавьте конфигурацию соответствующего сайта
vim /usr/local/etc/nginx/nginx.conf
// 添加以下3个代理
server {
listen 1280;
server_name passport.xxx.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:11000;
}
}
server {
listen 1280;
server_name testssoa.xxx.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:11001;
}
}
server {
listen 1280;
server_name testssob.xxx.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:11002;
}
}
- nginx -t проверяет правильность конфигурации
- nginx -s перезагрузить перезапустить nginx
Подготовьте простую страницу входа
Страница выглядит следующим образом.Здесь подготовлены два доменных имени,testsoa иtestsob.Чтобы поделиться страницей, я использую решение, которое заключается в том, чтобы отображать страницу напрямую через узел, и порт должен быть запущен в соответствии с номер порта, настроенный nginx выше. Службы указаны как 11001 и 11002.// package.json
"scripts": {
"start": "babel-node passport.js",
"starta": "cross-env NODE_ENV=ssoa babel-node index.js",
"startb": "cross-env NODE_ENV=ssob babel-node index.js"
}
// index.js
import express from 'express' // import需要babel支持
const app = express()
const mapPort = {
'ssoa': 11001,
'ssob': 11002
}
const port = mapPort[process.env.NODE_ENV]
if (port) {
console.log('listen port: ', port)
app.listen(port)
}
Просто настройте его, чтобы вы могли запускать два сервера напрямую через npm run starta и npm run startb.
Конкретные шаги
1. Логин пользователя
Все входы инициируются в paspport. Здесь jwt используется для поддержания состояния входа пользователя (учитывая сторону приложения). После успешного входа токен будет сохранен в Redis, а токен будет записан в верхний уровень доменное имя xxx.com, чтобы разные подсистемы могли получать токены, а установка httpOnly может предотвратить некоторые атаки xss.
app.post('/login', async (req, res, next) => {
// 登录成功则给当前domain下的cookie设置token
const { username, password } = req.body
// 通过 username 跟 password 取出数据库中的用户
try {
const user = await authUser(username, password)
const lastToken = user.token
// 此处生成token,此处使用jwt
const newToken = jwt.sign(
{ username, id: user.id },
tokenConfig.secret,
{ expiresIn: tokenConfig.expiresIn }
)
// 保存token到redis中
await storeToken(newToken)
// 生成新的token以后需要清除子系统的session
if (lastToken) {
await clearClientStore(lastToken)
await deleteToken(lastToken)
}
res.setHeader(
'Set-Cookie',
`token=${newToken};domain=xxx.com;max-age=${tokenConfig.expiresIn};httpOnly`)
return res.json({
code: 0,
msg: 'success'
})
} catch (err) {
next(new Error(err))
}
})
2. Доступ пользователя к защищенным ресурсам (процесс аутентификации)
После успешного входа в систему мы можем попытаться получить защищенные ресурсы.Поскольку паспорт устанавливает файл cookie для доменного имени xxx.com, как a.xxx.com, так и b.xxx.com могут использовать файл cookie для перехода на свои соответствующие серверы. , чтобы инициировать запрос ресурсов. Как упоминалось ранее, перед запросом ресурсов требуется аутентификация.После успешной аутентификации будет сгенерирована частичная сессия, и последующие запросы могут быть аутентифицированы в течение определенного периода времени.
// 发起一个认证请求
const authenticate = async (req) => {
const cookies = splitCookies(req.headers.cookie)
// 判断是否含有token,如没有token,则返回失败分支
const token = cookies['token']
if (!token) {
throw new Error('token is required.')
}
const sid = cookies['sid']
// 如果获取到user,则说明该用户已经登录
if (req.session.user) {
return req.session.user
}
// 向passport服务器发起一个认证请求
try {
// 这里的sid应该是存在redis里的key
let response = await axiosInstance.post('/authenticate', {
token,
sid: defaultPrefix + req.sessionID,
name: 'xxxx' // 可以用来区分具体的子系统
})
if (response.data.code !== 0) {
throw new Error(response.data.msg)
}
// 认证成功则建立局部会话,并将用户标识保存起来,比如这里可以是一个uid,或者也可以是token
req.session.user = response.data.data
req.session.save()
return response.data
} catch (err) {
throw err
}
}
Для подсистем, которым требуется доступ к SSO, единственное, что нужно сделать, — это инициировать аутентификацию, поэтому для самой подсистемы стоимость доступа очень низкая. Даже если методы реализации подсистем на разных языках будут разными, это не беда.Основное здесь инициировать аутентификацию на паспорт.Вам нужно только передать параметры, необходимые для аутентификации согласно договору.Остальное Все следует оставить паспорт, чтобы беспокоиться о.
После успешной аутентификации каждая подсистема получает определенные ресурсы.
3. Паспорт
Эта часть аутентификации в основном предназначена для проверки действительности токена: первый — проверить, существует ли токен в Redis, а второй — проверить, действительно ли токен все еще действителен и срок его действия истек, и проанализировать информацию о пользователе, содержащуюся в нем. проверка прошла успешно, необходимо зарегистрировать подсистему (сохранить ее в Redis и использовать токен в качестве ключа), чтобы облегчить последующий выход из системы. Здесь также добавлено небольшое суждение, то есть суждение x-real-ip может предотвратить определенную степень подлога.
app.post('/authenticate', async (req, res, next) => {
const { token, sid, name } = req.body
try {
// 检查请求的真实IP是否为授权系统
// nginx会将真实IP传过来,伪造x-forward-for是无效的
if (!checkSecurityIP(req.headers['x-real-ip'])) {
throw new Error('ip is invalid')
}
// 判断token是否还存在于redis中并验证token是否有效, 取得用户名和用户id
const tokenExists = await redisClient.existsAsync(token)
if (!tokenExists) {
throw new Error('token is invalid')
}
const { username, id } = await jwt.verify(token, tokenConfig.secret)
// 校验成功注册子系统
register(token, sid, name)
return res.json({
code: 0,
msg: 'success',
data: { username, id }
})
} catch (err) {
// 对于token过期也应该执行一次clear操作
next(new Error(err))
}
})
4. Ссылка для отмены
Когда пользователь добровольно выходит из подсистемы, необходимо выйти из всех подсистем в домене.Поскольку информация, связанная с сеансом, ранее хранилась в Redis, все эти сеансы необходимо очищать при выходе из системы, иначе это может вызвать проблему, связанную с тем, что подсистема может по-прежнему получать ресурсы в течение определенного периода времени. Здесь я даюclearClientStore(token)
а такжеdeleteToken(token)
эти две функции.
Проблемное мышление и резюме
На самом деле весь процесс SSO относительно понятен, но до этого было довольно сложно и сложно (может быть, это было просто тяжело для моего фронтенда), за этот период я тоже столкнулся со многими странными проблемами. мое собственное мышление часто Проблема кривого хождения, с другой стороны, заключается в том, что я недостаточно опытен, чтобы пересечь реку, нащупывая камни. Столкнувшись с проблемами в течение этого периода, я также прочитал некоторые реализации исходного кода, такие как express-session и connect-redis, чтобы понять.
Возникшие проблемы и решения
- При использовании экспресс-сессии я использовал regenerate для регенерации сессии, и мне было интересно, что моя сессия не сгенерировала.Позже, под руководством большого парня, я спокойно посмотрел исходный код и обнаружил, что некоторые вещи были сделаны промежуточным программным обеспечением.Теперь, для операций сеанса, мне нужно только сделать простейший set и get.
- Redis не удалось прочитать значение ключа сеанса. Эта проблема обнаружена в исходном коде connect-redis. По умолчанию он добавит префикс к sid.
'sess:'
, поэтому при получении sid от redis вы должны получитьget prefix + sid
Я глубоко осознаю, что когда проблема не может быть решена с трудностями, это должна быть проблема с предыдущим мышлением. В это время мы должны успокоиться и найти корень проблемы. Для программистов самый эффективный способ найти корень проблемы Просто прочитайте исходный код.
В процессе проектирования мы также учитываем, как снизить стоимость доступа к подсистеме (требуется только один шаг аутентификации), соображения безопасности (только http, фильтрация RealIP, срок действия сеанса и т. д.), соображения производительности (локальный сеанс и Redis). )
Наконец, полный пример кода прилагается恳请各位大佬给个Star吧,小弟在此跪谢了
, папка config игнорируется в коде, и есть только один элемент конфигурации базы данных и параметр соли. Паспорт должен быть готов к непосредственному использованию с некоторыми корректировками.
Есть еще много непродуманных мест, надеюсь, вы подскажете.