Pull to refresh

Oauth 2.1 spring authorization server + SPA

Reading time4 min
Views7.5K

Доброго всем дня, уважаемые хабровчане!

До сего момента я являлся лишь читателем этого замечательного ресурса, но вот кажется и пришло время написать мою первую статью.

Oauth 2.1 - дальнейшее развитие популярного фреймворка авторизации Oauth 2.0, который на момент написания статьи всё ещё вроде как находится в стадии черновика. Но тем не менее уже начинает применяться. На хабре уже есть более подробная статья на эту тему.

Из не очень приятного, из Oauth 2.1 убраны варианты получения токена:

  • implict

  • password

Но взамен мы получаем поддержку PKCE как для публичных клиентов, так и для приватных.

И вот хочу вынести на ваш суд небольшой пример реализации получения токенов на spring authorization server (на момент написания статьи версия 0.3.1) и SPA на Vue.js.

Немного кода:

@Bean
    public RegisteredClientRepository registeredClientRepository() {
        
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("browser-client")
            .clientSecret("{noop}secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("http://127.0.0.1:8081/code")
            .scope(OidcScopes.OPENID)
            .scope("browser.read")
            .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

На сервере регистрируем клиента и в первую очередь интересует нас вот эта строка .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)

она настраивает то, каким способом будет авторизоваться наш браузерный клиент, в случае NONE, авторизация клиента не требуется, но в этом случае, будут выданы только access_token, и если необходимо id_token, refresh_token в случае публичного клиента выдаваться не будет.

Теперь код клиента:

    login() {
      var codeVerifier = this.generateRandomString(64);

      Promise.resolve()
          .then(() => {
            return this.generateCodeChallenge(codeVerifier)
          })
          .then(function(codeChallenge) {
              window.sessionStorage.setItem("code_verifier", codeVerifier)

              let args = new URLSearchParams({
                  response_type: "code",
                  client_id: 'browser-client',
                  redirect_uri: 'http://127.0.0.1:8081/code',
                  state: '1234zyx',
                  code_challenge: codeChallenge,
                  code_challenge_method: 'S256',
                  scope: 'openid browser.read'
              });
              window.location = "http://127.0.0.1:9000/oauth2/authorize?" + args;
      });
    },

    async generateCodeChallenge(codeVerifier) {
        var digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
        return btoa(String.fromCharCode(...new Uint8Array(digest)))
            .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
    },

    generateRandomString(length) {
        var text = "";
        var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

        for (var i = 0; i < length; i++) {
            text += possible.charAt(Math.floor(Math.random() * possible.length));
        }

        return text;
    }

Формируем url для перехода на сервер авторизации, тут всё стандартно, разве что не нужно указывать client_secret, а вместо него формируются 2 поля code_challenge и code_challenge_method. code_challenge - альфанумерик произвольная строка и code_challenge_method - метод её шифрования. Они будут запомнены на сервере и при обмене кода доступа на токен будут проверяться.

Так же нам в браузере необходимо сохранить исходную строку window.sessionStorage.setItem("code_verifier", codeVerifier), в запросе обмена кода на токен эта строка так же будет отправляться на сервер и будет там сверена с отправленными ранее code_challenge и code_challenge_method. Вот собственно вторая часть кода, обмен кода доступа на токен:

router.beforeEach((to, from, next) => {
  if (to.path == '/code' && to.query.code != null) {
    let formData = new FormData()
    formData.append('grant_type','authorization_code')
    formData.append('code',to.query.code)
    formData.append('redirect_uri','http://127.0.0.1:8081/code')
    formData.append('client_id','browser-client')
    formData.append('code_verifier',window.sessionStorage.getItem("code_verifier"))

    axios.post('http://127.0.0.1:9000/oauth2/token',
    formData,
    {
      headers: {
        'Content-type':'application/url-form-encoded'
      }
    }
    ).then(resp => {
      console.log(resp.data)
      window.sessionStorage.setItem("_a", resp.data.access_token);
    })
    next({name: 'Index'})
  } else {
    next()
  }
})

Так как я использовал Vue.js и vue-router перехватом вызова занимается непосредственно роутер. И так если у нас произошёл вызов с путём /code и в запросе присутствует параметр code, роутер его перехватит, сформирует форму и отправит её на эндпоинт обмена кода на токен и в ответ мы получим собственно access_token (и id_token если у нас на сервере настроен .scope(OidcScopes.OPENID) и в первом запросе в скопах есть scope: 'openid').

Теперь немного нюансов.

Если на сервере у нас метод авторизации клиента .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC), а в запросе обмена кода на токен мы добавим заголовок 'Authorization':'Basic '+btoa('browser-client:secret'), то наш клиент становится конфиденциальным и в этом случае кроме access_token мы так же получим и refresh_token. Но как говорит нам спецификация, рефреш токен не должен храниться в браузере, так как нет способа гарантированно хранить его там безопасно.

Весь код можно посмотреть на GitHub.

На этом пожалуй всё, Надеюсь статья будет кому то полезна и интересна.

Спасибо!

Tags:
Hubs:
Total votes 6: ↑6 and ↓0+6
Comments0

Articles