Доброго всем дня, уважаемые хабровчане!
До сего момента я являлся лишь читателем этого замечательного ресурса, но вот кажется и пришло время написать мою первую статью.
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.
На этом пожалуй всё, Надеюсь статья будет кому то полезна и интересна.
Спасибо!