D
D
dpablo_escobarr2021-01-11 12:19:31
Node.js
dpablo_escobarr, 2021-01-11 12:19:31

Authorization. What is the best way to organize it?

Actually, I would like to know how to correctly and safely organize authorization for your application in 2021. I want offline authorization (without authorization through google or facebook, etc.).

Answer the question

In order to leave comments, you need to log in

1 answer(s)
V
Valery Kulakov, 2021-01-15
@dpablo_escobarr

Let's assume for definiteness that there is a stack of Express.js (server architecture) + Passport.js (library for authorization) + Mongoose.js (ODM for Mongo database) + React.js (client). It is worth noting that if offline authorization is needed and OAuth is definitely not required in the future, then Passport can not be used. In this case, everything is quite simple to implement on your own. Passport is necessary (in my opinion) mainly for the unification of all authorization methods.
Now let's decide on the degree and method of protection. The most popular solution among tokens is JWT tokens. The point of using them is that

  • JWT is self contained. Having a valid token, you do not need to pull the database (as in the case of login / password) to check the user's rights.
  • JWT can be decrypted but cannot be spoofed . If Vasya has a token given to him, he can fake it and pretend to be Vanya, but the server will recognize this and give an error.

But JWT can be hijacked. How? For example, physically gaining access to the user's computer during an active session (open the browser console and write a short code, or look at the Redux store if it is used - see the screenshot - this is my profile page in one of the online stores).
600151069e986774161150.jpeg
How to be in that case? First, the token has a lifetime. When the token "goes bad", the attacker will need to enter a login / password, but he does not know them. In fact, any system can be hacked, it's just a question of complexity.
There is a more advanced scheme. This scheme uses two tokens: access and refresh. Access token (access)has a short lifetime (usually 20 minutes, but it can vary and depends mainly on the intensity of use of the designed web application: if requests to the server are made rarely, say, once an hour, its lifetime can be increased), the refresh token has a long time life (several days, or even weeks - again depends on the degree of protection / intensity of use). At the same time, if we need to implement the "remember me" functionality (autologin), we will store refresh, say, in the browser's LocalStorage. At the same time, it is recommended not to "silently" remember the user, but to check the "remember me" authorization form - so that the user understands that there is a risk of stealing the token. By the way, where what to store on the client.
  • The username/password is never stored anywhere .
  • Access token is stored in RAM (in Redux-store or, say, in axios.defaults.headers.common).
  • Refresh - if you need an autologin function - store in LocalStorage, if not needed - also in RAM.

Let's make authorization with autologin. To do this, we will store sessions in the database, and also give the user the opportunity to "log out from all devices" and delete a specific session (by the way, this is now implemented in Laravel authorization by default). What to store as a session in the database? This is a refresh-string + some information about the client device (maybe ip, browser version, OS version, location - all this is easily obtained by the server from the request headers, the location is calculated by ip). You can store any non- confidential information in an access token ( you can’t: password, credit card details, etc.), I will store just the username. The refresh token can be just a random unique string, but I will encrypt it additionally in JWT in order to check the lifetime and validity without going into the database. So, let's get started (right to the point, I'm not writing about installing packages and creating schemes for Mongoose, etc., so that the post does not turn out to be endless).
First we need to initialize Passport.js. We need two strategies: local for initial login via login/password and JWT. We will send the token to the server through the "authorization" header.
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;

Next, implement the following routes (and controllers for them):
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;

We design controllers "thin" (in short, the controller calls the model method, not 100 of its methods), for example:
module.exports.logIn = async (req, res, next) => {
    req.user.installSession(req.useragent)
        .then(newSessionData => {
            res.json(newSessionData);
            next();
        });
};

In this case, installSession may look something like this (divided into methods by meaning, the same methods are used in other cases, for example, when refreshing a session, we use 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 });
};

Conclusion in a comment to this answer (slightly missed 10,000 characters)

Didn't find what you were looking for?

Ask your question

Ask a Question

731 491 924 answers to any question