dpablo_escobarr
@dpablo_escobarr

Авторизация. Как лучше всего ее организовать?

Собственно хотелось бы знать, как в 2021 правильно и безопасно организовать авторизацию для своего приложения. Хочу именно автономную авторизацию (без авторизации через google или facebook и т.д).
  • Вопрос задан
  • 74 просмотра
Решения вопроса 1
KulakovAngel
@KulakovAngel
Full Stack Developer (Node.JS)
Давайте для определенности предположим, что имеется стек Express.js (серверная архитектура) + Passport.js (библиотека для авторизации) + Mongoose.js (ODM для бд Mongo) + React.js (клиент). Стоит заметить, что, если нужна именно автономная авторизация и в будущем точно не потребуется OAuth, то Passport можно и не использовать. В данном случае все достаточно просто можно реализовать самостоятельно. Passport необходим (на мой взгляд) в основном для унификации всех способов авторизации.

Теперь определимся со степенью и способом защиты. Наиболее популярное решение среди токенов - JWT-токены. Смысл их использования в том, что
  • JWT самодостаточный. Имея валидный токен, не нужно дергать базу (как в случае логин/пароль), чтобы проверить права пользователя.
  • JWT можно расшифровать, но нельзя подделать. Если у Васи есть выданный ему токен, он сможет его подделать и прикинуться Ваней, но сервер это узнает и выдаст ошибку.


Но JWT можно угнать. Как? Например, физически получив доступ к компьютеру пользователя во время активной сессии (открыть консоль браузера и написать короткий код, или посмотрев Redux store, ежели он используется - см. скриншот - это стор моего профиля в одном из онлайн-магазинов).
600151069e986774161150.jpeg

Как быть в таком случае? Во-первых, токен имеет время жизни. Когда токен "протухнет", злоумышленнику потребуется ввести логин/пароль, а их он не знает. На самом деле любую систему можно взломать, вопрос лишь в сложности.

Существует более продвинутая схема. В данной схеме используется два токена: access и refresh. Токен доступа (access) имеет короткое время жизни (обычно 20 минут, но может варьироваться и зависит в основном от интенсивности использования проектируемого веб-приложения: если запросы к серверу выполняются редко, скажем, раз в час - время его жизни можно увеличить), refresh-токен имеет длинное время жизни (несколько дней, а то и недель - опять же зависит от степени защиты/интенсивности использования). При этом, если нам нужно реализовать функционал "запомнить меня" (автологин), refresh будем хранить, скажем, в LocalStorage браузера. При этом рекомендуют не "молча" запоминать пользователя, а сделать флажок на форме авторизации "запомнить меня" - чтобы пользователь понимал, что есть риск угона токена. Кстати, где что хранить на клиенте.
  • Логин/пароль никогда нигде не храним.
  • Access токен хранят в оперативной памяти (в Redux-store или, скажем, в axios.defaults.headers.common).
  • Refresh - если нужна функция автологина - храним в LocalStorage, если не нужна - тоже в оперативной памяти.


Сделаем авторизацию с автологином. Для этого сессии будем хранить в базе данных, а также дадим пользователю возможность "выйти со всех устройств" и удаления конкретной сессии (кстати, в авторизации Laravel теперь это реализовано по умолчанию). Что хранить в качестве сессии в БД? Это refresh-string + некая информация об устройстве-клиенте (может быть ip, версия браузера, версия ОС, местоположение - все это легко получается сервером из заголовков запроса, местоположение вычисляется по ip). В access-токене можно хранить любую неконфеденциальную информацию (нельзя: пароль, данные кредитных карт и т.д.), я буду хранить просто имя пользователя. Рефреш-токен может представлять собой просто рандомную уникальную строку, но я зашифрую ее дополнительно в JWT, чтобы проверять срок жизни и валидность, не лезя в БД. Итак, приступим (сразу к сути, не пишу про установку пакетов и создание схем для Mongoose и т.п., чтобы пост не получился бесконечным).

Для начала нам нужно инициализировать Passport.js. Нам нужны две стратегии: локальная для первоначального входа через логин/пароль и JWT. Токен серверу будем передавать через заголовок "authorization".
passport.use('byUsernameAndPassword', // я так назвал свою стратегию
    new LocalStrategy({
        usernameField: 'username',
        passwordField: 'password',
        // passReqToCallback: true,
        session: false,
    }, (username, password, done) => {
        User
            .verifyByUsernameAndPassword(username, password) // примечание: это, конечно, не базовый метод, он написан "вручную"
            .then(user => done(null, user))
            .catch(error => done(error, false, error.message));
    })
);

passport.use(
    new JwtStrategy({
        jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),// passport сам дешифрует токен и проверит его валидность, хотя это и не сложно сделать вручную, если решите passport не использовать
        secretOrKey: process.env.ACCESS_SECRET,
        session: false,
    }, ({ username }, done) => {
        // на самом деле можно и не доставать пользователя из базы, просто расшифровать jwt, как я уже писал.
        // Доставать из базы можно только тогда, когда его нужно поменять (скажем, пользователю нужно добавить заказ в корзину)
        // меньше запросов к БД - быстрее работает
        User
            .findOne({ phoneNumber: username })
            .then(user => {
                if (!user) return done(null, false, 'This user has been deleted or changed. Please, login again.');
                return done(null, user);
            })
            .catch(err => done(err));
    })
);

module.exports = passport;


Далее реализуем следующие маршруты (и контроллеры для них):
const auth = require('../controller/auth');
const router = express.Router();
router.post('/login', passport.authenticate('byUsernameAndPassword'), auth.logIn);

const sessionRouter = express.Router();

// из Refresh-токена извлекаем собственно строку
function decodeRefreshStringFromToken(refreshToken, errorCallback) {
    const decoded = require('jsonwebtoken').verify(refreshToken, process.env.REFRESH_SECRET, (error, decoded) => {
        return error ? error : decoded;
    });
    if (decoded instanceof Error) // обработка ошибок
    return decoded.refreshToken;
}

// для любой работы с сессией в теле запроса будем ожидать Refresh-токена
sessionRouter.use((req, res, next) => {
    if (!req.body.refreshToken) return next(new RefreshTokenUnauthorizedError());
    res.locals.refreshString = decodeRefreshStringFromToken(req.body.refreshToken, next);
    return next();
});

// выход - удаление сессии из БД
sessionRouter.post('/logout', auth.logOut); // todo: jwtAuthMiddleware here&&
// сессия становится неактивной (пользователь закрыл приложение) - зачем нужен данный роут? В БД у сессии будем хранить поле isActive. Если пользователь сидит сразу с двух устройств - подозрительно)
sessionRouter.post('/interrupt', auth.interruptSession);
// обновляем сессию, когда время жизни Refresh-токена вышло
sessionRouter.post('/refresh', auth.refreshSession);

router.use('/session', sessionRouter);
module.exports = router;


Контроллеры проектируем "тонкими" (если коротко, контроллер вызывает метод модели, а не 100 ее методов), например:
module.exports.logIn = async (req, res, next) => {
    req.user.installSession(req.useragent)
        .then(newSessionData => {
            res.json(newSessionData);
            next();
        });
};


При этом installSession может выглядеть как-то так (разделен на методы по смыслу, эти же методы используются и в других случаях, например, при рефреше сессии задействуем makeSessionData):
userSchema.methods.installSession = async function(useragent) {
    const newSession = this.createSession(useragent); // метод реализован ниже

    if (this.sessions.length > 5) this.sessions = []; // допустим ограничение максимум в 5 сессий
    if (this.sessions.find(session => session.isActive)) {// выходить из всех активных сессий, может даже удалять их}
    this.sessions.push(newSession);
    await this.save();
    return this.makeSessionData(newSession.refreshToken);
};

userSchema.methods.createSession = function({ browser, os, platform }) {
    return {
        refreshString: this.generateRefreshString(),
        browser,
        os,
        platform,
    };
};

userSchema.methods.makeSessionData = function(refreshString) {
    const {
        id,
        name,
        phoneNumber,
        role,
    } = this;
    const refreshToken = this.makeRefreshJwtToken(refreshString);

    return ({ id, name, phoneNumber, role, refreshToken, accessToken: this.makeAccessJwtToken({ username: this.username }), tokenType: 'Bearer', expiresIn: process.env.ACCESS_EXPIRES_IN, });
};

userSchema.methods.makeRefreshJwtToken = function(refreshString) {
    return jwt.sign(
        { refreshString },
        REFRESH_SECRET,
        { expiresIn: REFRESH_EXPIRES_IN });
};

Заключение в комментарии к данному ответу (чуть-чуть не уложился в 10 000 символов)
Ответ написан
Пригласить эксперта
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы
Яндекс Москва
от 100 000 до 300 000 ₽
Яндекс Санкт-Петербург
от 100 000 до 300 000 ₽
от 120 000 до 160 000 ₽