О JWT

2017-09-03

А как вы ограничиваете доступ к вашему API?

Понятно, что в нашей аутсорсной разработке, когда это приватное API какого-то сервиса внутри конторы заказчика, можно понаставить огненных заборов, и вообще сделать API доступным только из приватной сети.

А если это публичное, да и к тому же многопользовательское API? Как вы аутентифицируете и авторизуете пользователей? Это ведь API, тут нет формы логина.

API

Тупой, но вполне для начала работающий способ: Basic Authentication. Можно даже взгромоздить проверку паролей на тот же Nginx.

Но, простите, о каких пользователях идёт речь? Это же API. Это не пользователи. Это некие программные агенты стучатся к вам в интересах пользователя.

Поэтому, чтобы не смущать себя эфемерными пользователями и их паролями, клиентам API выдают некие токены. Токен, в данном случае, некая строка, идентифицирующая данного клиента, а иногда и права, выданные клиенту.

Login form

Вопрос первый: где и как получать токены?

На эту тему сломано немало копий. Но, похоже, в случае веб-сервисов, побеждают разные варианты OAuth.

В OAuth для выдачи токена требуются явное участие и согласие юзера, в интересах которого агент будет действовать. Для этого задействуется веб-браузер, а пользователя явно просят в этом браузере по-человечески авторизоваться на сервисе, который будет предоставлять API. Т.е. на вашем сервисе.

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

Если у вас не публичный веб-сервис, а просто бэкенд какого-то вебдванольного приложения, можно, конечно, поступить проще. Просто сделайте метод, который за логин-пароль юзера будет выдавать токен.

OAuth 2.0

Вопрос второй: как передавать токен?

HTTP предоставляет уйму способов. Есть заголовки запроса. Есть параметры запроса. Есть тело запроса (в тот же JSON самого запроса токен добавить). Все варианты имеют место быть.

Более-менее стандартным таки является заголовок Authorization. Причём у него есть тип.

Authorization: <type> <credentials>

Для базовой аутентификации тип — это «Basic». Для передачи токенов в OAuth 2.0 тип — это «Bearer». Ничто не мешает придумать свой «тип», только сначала напишите для этого RFC.

Понятно, что в любом случае токен передаётся открытым текстом. И тот, кто перехватит токен, сможет им воспользоваться, выдав себя за чужого агента. Поэтому, только HTTPS. Шифруемся.

Если у вас не HTTP/HTTPS или не только HTTP/HTTPS, то ничего другого не остаётся, кроме как включить токен одним из параметров запроса (то бишь, в тело запроса). Почему бы и нет.

Authorized

Вопрос третий: где хранить и как проверять токены?

На клиентской стороне, если это браузер, уверенно побеждает LocalStorage. Храните токены там, подсовывайте в запросы к API, и будет вам счастье.

Интереснее, что происходит на серверной стороне. Что API делает с токенами?

Очевидный подход. Пусть токен — это просто случайная строка достаточной длины. Сохраним токен в таблицу (или коллекцию) БД, запишем дату его выдачи (или дату окончания действия), и свяжем с нужными правами нужного пользователя. Всё. Работает. Просто, понятно.

Но на больших масштабах у этого подхода проявляются недостатки. На каждый запрос к API, требующий авторизации, независимо от сути самого запроса, требуется просмотр этой таблицы БД на предмет поиска и проверки токена. Если разных API у нас несколько, и сами API горизонтально отмасштабированы на кучу серверов, обеспечить для всех них актуальный доступ к одной таблице становится не очень простой задачей. К тому же, при миллионах пользователей, токенов может быть ещё больше. Сама таблица может получиться очень не маленькой.

Можно обойтись без всего этого. Пусть сам токен хранит в себе всё, что нужно для идентификации пользователя, выяснения и проверки его прав. Тогда большая таблица не понадобится. Не нужно будет делать в неё запрос. А каждый сервер с API может проверить токен самостоятельно.

А чтобы хитрый пользователь не подделал токен, мы будем его подписывать. В криптографическом смысле. Ключом, известным только серверу (серверам).

JWT and friends

Слава богу, ничего на этом поприще изобретать не надо. Ибо есть стандарт JWT — JSON Web Tokens.

JSON, потому что всякие служебные данные и сведения о пользователе представлены в виде JSON.

Типичный JWT токен выглядит так:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

Расшифровать можете на упомянутом jwt.io.

Это три «слова» в base64 кодировке, разделённые точкой.

Первое «слово» — заголовок. Описывает, какие алгоритмы мы используем для подписи и какого типа содержимое токена используем.

$ echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
{"alg":"HS256","typ":"JWT"}

В данном случае «HS256» означает HMAC SHA256, алгоритм подписи такой. А «JWT» означает, что тело токена — действительно JSON в понятиях JWT.

Второе «слово» — тело токена.

$ echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9" | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true}

Это JSON, который содержит некоторые свойства, которые в JWT называют claims, т.е. утверждения.

Некоторые claims определены стандартом JWT. Они, для компактности, трёхбуквенные. iss, issuer — идентификатор того, кто выдал токен. sub, subject — кому выдан токен, например, это идентификатор пользователя. exp, expiration time — время жизни токена, в виде unix timestamp в секундах. iat, issued at — момент выдачи токена, в виде unix timestamp в секундах.

Остальные claims зависят от данного API. Можно их зарегистрировать, чтобы ни с кем не пересечься. А можно просто набросать любых слов или uri.

Третье «слово» — подпись, в формировании которой используется некоторый секрет.

$ openssl dgst -sha256 -mac HMAC -macopt key:secret -binary \
  <( echo -n "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9"; ) \
  | base64
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ=

Надо же, openssl умеет.

JWS token structure

В мире Java с JWT токенами можно работать, например, с помощью jjwt.

Генерировать токен можно примерно так.

fun generateToken(username: String): String {
    return Jwts.builder()
            .setSubject(username)
            .setExpiration(Date(System.currentTimeMillis() + EXPIRATION_TIME))
            .signWith(SignatureAlgorithm.HS512, SECRET)
            .compact()
}

Поместите это в какой-нибудь контроллер, который отвечает за логин и выдачу токенов. Можно добавить и любые другие Claims при необходимости.

Проверять токены можно с помощью Spring Security, который нужно соответствующим образом сконфигурить.

@Configuration
@EnableWebSecurity
open class WebSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                // And filter other requests to check the presence of JWT in header
                .addFilterBefore(JWTAuthenticationFilter(),
                        UsernamePasswordAuthenticationFilter::class.java)
    }

}

Проверять заголовок «Authorization» будет соответствующий фильтр.

class JWTAuthenticationFilter: GenericFilterBean() {

    private val SECRET = "secret"

    override fun doFilter(request: ServletRequest,
                          response: ServletResponse,
                          filterChain: FilterChain) {
        val authentication = getAuthentication(request as HttpServletRequest)

        SecurityContextHolder.getContext().authentication = authentication
        filterChain.doFilter(request, response)
    }

    private fun getAuthentication(request: HttpServletRequest): Authentication? {
        val token = request.getHeader("Authorization")
        if (token != null) {
            val user = Jwts.parser()
                    .setSigningKey(SECRET)
                    .parseClaimsJws(token.replace("Bearer ", ""))
                    .body
                    .subject

            return if (user != null)
                UsernamePasswordAuthenticationToken(user, null, listOf())
            else
                null
        }
        return null
    }

}

Ну а добраться до аутентифицированного пользователя можно стандартными для Spring Security средствами.

@RestController
open class UserController {

    @RequestMapping("/user")
    fun user(): UserResponse {
        val authentication = SecurityContextHolder.getContext().authentication
        val username = (authentication.principal as? UserDetails)?.username ?: authentication.principal.toString()
        return UserResponse(username)
    }

}

Проверяем.

$ curl http://localhost:8080/user
{"timestamp":1504417496948,"status":403,"error":"Forbidden","message":"Access Denied","path":"/user"}

$ curl http://localhost:8080/login
{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTUwNDQyNDcxNn0.a9P1SroxiSTUraFdhYwUMjY0tFdhehIRa-R3oOfuW5Ov0INjKH0bS3b57PLF8rhj3uEYXzcPtplID-ncJkWJZg"}

$ curl http://localhost:8080/user \
    -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTUwNDQyNDcxNn0.a9P1SroxiSTUraFdhYwUMjY0tFdhehIRa-R3oOfuW5Ov0INjKH0bS3b57PLF8rhj3uEYXzcPtplID-ncJkWJZg'
{"usename":"admin"}

Больше примеров и кода можно найти тут и здесь.

Spring

Spring Security, конечно же, уродлив. С нормальным middleware всё выглядит значительно красивее (потому что больше магии).

from flask_jwt import JWT

app = Flask(__name__)

app.config['JWT_AUTH_URL_RULE'] = '/api/v1/auth'
app.config['JWT_EXPIRATION_DELTA'] = timedelta(hours=2)

from app.models import User

def authenticate(username, password):
    # тут отрабатывается /api/v1/auth, по логину паролю извлекаем юзера
    user = User.query.filter(User.username == username).first()
    if user and check_password_hash(user.password, password):
        return user

def load_user(payload):
    # тут извлекаем юзера по телу токена
    user = User.query.get(payload['identity'])
    # тут конечно плохо, что за юзером лезем в базу, лучше его всего в токен засунуть
    return user

jwt = JWT(app, authenticate, load_user)


from flask_jwt import jwt_required, current_identity as user

@app.route('/users/get_user_info')
@jwt_required()
def get_user_info():
    # тут магическим образом юзер (точнее current_identity) стали доступны
    return jsonify(
        user_name=user.username,
        user_id=user.id
    )

Flask

Обратите внимание, что JWT токен не зашифрован. Он только подписан. Такие токены называют JWS — JSON Web Signature. Владелец токена, т.е. сам юзер, вполне может прочитать, что вы там про него храните.

Если вы не хотите лишний раз смущать пользователя, или же хотите уменьшить последствия компроментации токена (одно дело, получить доступ к API и узнать ID данного пользователя, другое дело, узнать email пользователя или даже пароли от других сервисов), то можно и зашифровать тело токена. Такие токены называют JWE — JSON Web Encryption.

Скучные подробности можно почитать тут.

JWE token structure

jjwt не умеет JWE. А вот какой-нибудь сишарпный Jose — умеет.

private string EncodeToken(User user)
{
    var payload = new TokenData
    {
        Id = user.Id,
        Email = user.Email,
        Name = user.DisplayName,
        exp = NowTimestamp() + _tokenTtl
    };
    return Jose.JWT.Encode(payload, _tokenEncryptSecret,
        JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256CBC_HS512, JweCompression.DEF);
}

private User DecodeToken(string token)
{
    var payload = Jose.JWT.Decode<TokenData>(token, _tokenEncryptSecret);
    if (payload.exp < NowTimestamp())
    {
        throw new Jose.IntegrityException("Token expired");
    }
    return new User
    {
        Id = payload.Id,
        Email = payload.Email,
        DisplayName = payload.Name
    };
}

Явисты, не расстраивайтесь. Для Явы тоже всё есть, просто другая библиотека.

JOSE — это Javascript Object Signing and Encryption. Общий термин, объединяющий JWT, JWS, JWE, а также JWK (JSON Web Key). Сначала добавляли буковку S — Simple, потом X — eXtensible, теперь, похоже, наступила мода на J.

JWT is JWS and JWE

Ещё раз. JWT — это самодостаточные токены, которые не требуют обращения к какой-либо БД, чтобы удостовериться, что этот токен был выдан определённому пользователю. Соответственно, их самих нет нужды хранить в какой-либо БД и делать по ним поиск.

Из этого проистекает недостаток: JWT токены почти невозможно отозвать. Ведь нет БД, с которой можно сверяться по вопросам валидности.

Конечно, есть тяжёлая артиллерия. Достаточно сменить ключ, которым подписываются и проверяются токены, на всех серверах. И все имеющиеся в ходу токены автоматически станут невалидными. В крайних случаях можно (и, пожалуй, нужно) поступать именно так.

А для уменьшения последствий компроментации в обычной жизни JWT токены нужно делать короткоживущими. Несколько часов, максимум дней.

Короткоживущие токены — это неудобно. Ведь юзеру придётся при протухании токена перелогиниваться.

Поэтому в OAuth используют два токена. Основной access token, короткоживущий, который может быть JWT, сам в себе содержит нужные данные и может быть проверен без доступа к БД. И refresh token, долгоживущий, может храниться и проверяться в БД, используется для обновления и выдачи нового access token.

Получается, мы можем получить все преимущества самодостаточности JWT, аутентифицируя тысячи запросов без лишнего обращения к БД, и при этом получить долгую, удобную для юзеров, сессию, с временем жизни refresh токена. Если, конечно, обновлять токены будем автоматически.