OAuth, OpenID Connect, Keycloak a Spring Boot IV. – Flow pre Authorization Code

2023/03/29

Ukážme si, ako je možné dodať do aplikácie v Spring Boote prihlásenie pomocou Keycloaku.

Aplikácia bude ponúkať jednak REST API a jednak statickú stránku v HTML. Ktokoľvek, kto bude chcieť používať aplikáciu, sa bude môcť prihlásiť svojou identitou, ktorá je udržiavaná v Keycloaku, cez prihlasovací formulár zabezpečený Keycloakom.

Vytvorenie Spring Boot aplikácie

Vytvorme úplne novú aplikáciu v Spring Boote.

Do závislostí dodajme

  • štartér pre Spring Boot OAuth 2 Client (org.springframework.boot:spring-boot-starter-oauth2-client)

  • štartér pre web (org.springframework.boot:spring-boot-starter-web)

Flow prihlásenia

Prihlásenie bude používať OAuth 2.0 / OpenID Connect, a flow Authorization Code.

Flow je dokumentovaný na viacerých miestach.

Stručne:

  1. Používateľ vymení login a heslo za autorizačný kód (authorization code).

  2. Autorizačný kód prepošle do springovej backendovej aplikácie.

  3. Springová aplikácia vymení autorizačný kód a citlivé tajomstvo klienta (client secret) za identifikačný token (ID Token), ktorý považuje za potvrdenie o prihlásení.

  4. Informácia o prihlásení na strane servera je udržiavaná prostredníctvom HTTP Cookie.

Vytvorenie nového klienta

Vytvorme v administrátorskej konzole Keycloaku nového klienta.

add new client

Na rozdiel od ROPC klienta:

  • pomenujme ho gigabank,

  • zapnime Client Authentication: keďže aplikácia bude mať uzavretý zdrojový kód a bude bezpečne na serveri, bude vedieť bezpečne manipulovať s citlivým údajom Client Secret.

  • vypnime všetky flowy, okrem Standard Flow, čo je iné označenie pre Authorization Code Flow v terminológii OAuth/OIDC.

V ďalšom kroku vyplňme Root URL našej springovskej aplikácie, ktorým bude http://localhost:8888. (Na tejto adrese pobeží naša aplikácia.)

Na karte Credentials získajme citlivý Client Secret, ktoré sa bude používať na získanie tokenu JWT.

client secret

Uistime sa, že v Client Authenticator budeme používať Client ID and Secret, teda identifikátor klienta spolu s citlivým „tajomstvom“.

Client Secret použijeme v konfigurácii aplikácie v Spring Boot.

Konfigurácia Spring Boot

Dokonfigurujme Spring Boot, najmä application.properties.

application.properties
server.port=8888 (1)
spring.security.oauth2.client.registration.keycloak.client-id=gigabank (2)
spring.security.oauth2.client.registration.keycloak.client-secret=EzPgZowPLvDzY7fTylCD893W4Nh1UC4t (3)
spring.security.oauth2.client.registration.keycloak.scope=openid (4)
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8080/realms/master (5)
1 Port, na ktorom beží aplikácia. Pozor, tento port je už zaregistrovaný v Keycloaku ako Root URL.
2 Názov klienta z Keycloaku.
3 Citlivý Secret Key z Keycloaku.
4 Budeme používať protokol OpenID, teda tokeny JWT a autentifikáciu podľa tohto protokolu.
5 Adresa URL ku Keycloaku, z ktorej sa stiahnu kľúče a ďalšie metadáta.

Spustenie aplikácie

Spusťme Spring Boot a navštívme http://localhost:8888 z prehliadača.

Prehliadač nás rovno presmeruje na prihlasovaciu stránku Keycloaku, kde môžeme vyplniť login a heslo (harald a príslušné heslo z predošlých dielov). Po úspešnom prihlásení sa ocitneme na …​ chybovej stránke, keďže v našej aplikácii na adrese / nemáme nič.

Aspoň sme sa prihlásili!

Ak by sme zapli logovanie, uvideli by sme hlášky zodpovedajúce flowu:

FilterChainProxy                     : Securing GET /
DefaultRedirectStrategy              : Redirecting to http://localhost:8888/oauth2/authorization/keycloak
FilterChainProxy                     : Securing GET /oauth2/authorization/keycloak
DefaultRedirectStrategy              : Redirecting to http://localhost:8080/realms/master/protocol/openid-connect/auth?response_type=code&client_id=gigabank&scope=openid&state=jaXNvQifZfNBTv7rhGsIDsP4yBPvY0UvkIo6bP_6i68%3D&redirect_uri=http://localhost:8888/login/oauth2/code/keycloak&nonce=RbvtU5VSAb_7XEClpkOzcvZCx_haCynK-ArhvVOmLJM
FilterChainProxy                     : Securing GET /login/oauth2/code/keycloak?state=jaXNvQifZfNBTv7rhGsIDsP4yBPvY0UvkIo6bP_6i68%3D&session_state=656a1414-f9af-4312-9d49-e7746c757974&code=8fafbb12-36b6-4d6e-b68e-489a04e2b5bd.656a1414-f9af-4312-9d49-e7746c757974.daaff20b-b0e8-4b9b-bbcf-f73e0a5f636a
HttpURLConnection                    : sun.net.www.MessageHeader@72ce10a18 pairs: {POST /realms/master/protocol/openid-connect/token HTTP/1.1: null}{Accept: application/json;charset=UTF-8}{Content-Type: application/x-www-form-urlencoded;charset=UTF-8}{Authorization: Basic Z2lnYWJhbms6RXpQZ1pvd1BMdkR6WTdmVHlsQ0Q4OTNXNE5oMVVDNHQ=}{User-Agent: Java/17.0.5}{Host: localhost:8080}{Connection: keep-alive}{Content-Length: 223}
RestTemplate                         : HTTP GET http://localhost:8080/realms/master/protocol/openid-connect/userinfo
HttpURLConnection                    : sun.net.www.MessageHeader@78367bd16 pairs: {GET /realms/master/protocol/openid-connect/userinfo HTTP/1.1: null}{Accept: application/json}{Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJVMEwtSUI3aGtOQUc4T3hPNVF2SEg4OHMxa24yRDN1d3dLN3haUlVfNkRFIn0.eyJleHAiOjE2ODAwMTI2MDcsImlhdCI6MTY4MDAxMjU0NywiYXV0aF90aW1lIjoxNjgwMDEyNTQ3LCJqdGkiOiI2MjA0YTZiNy1iMDY4LTQxZmQtOTc1MS1jZTMwZjZiMDE4ODQiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL21hc3RlciIsImF1ZCI6WyJtZWdhYmFuayIsImFjY291bnQiXSwic3ViIjoiMGYwZDdmZTktOTI5My00ZWY0LWE0NzYtOWUyYWJhNzMwMjhjIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZ2lnYWJhbmsiLCJub25jZSI6IlJidnRVNVZTQWJfN1hFQ2xwa096Y3ZaQ3hfaGFDeW5LLUFyaHZWT21MSk0iLCJzZXNzaW9uX3N0YXRlIjoiNjU2YTE0MTQtZjlhZi00MzEyLTlkNDktZTc3NDZjNzU3OTc0IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vbG9jYWxob3N0Ojg4ODgiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbWFzdGVyIiwib2ZmbGluZV9hY2Nlc3MiLCJjcmVkaXRvciIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsibWVnYWJhbmsiOnsicm9sZXMiOlsid2l0aGRyYXdlciJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6IjY1NmExNDE0LWY5YWYtNDMxMi05ZDQ5LWU3NzQ2Yzc1Nzk3NCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiaGFyYWxkIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIn0.ZDeIAMNl7GdQXaZXL-FYLrlhcvMjEbfBmEy4j-_Dv6NwD8E9YPevVModjaaYH5Bu0sUh3bb2FH5U2_NiHH2ab-fboCCRq15AweD7z80fJ2UqK6dU9pi1_Sc_23Wy18Rj4NcxMvbn6VndN3cWKcFdkFWI6HbfkxPfMVYB7Wq_pSHTtSS_SZ6EINYAWiYT5p9v08yNd2fQ37nWbUrcCnGe8bWruXfEINox3sRIiFEAgOi0Jf_REZUVGCs5VawdLYF5eP8iEl7iB0ifN2i5F4gBCsxeDDiuD0BWzjtYXJraVGe9GXYyZ5lxsPptHMHnZ3me6W96KEzFNfOzprNemTmixA}{User-Agent: Java/17.0.5}{Host: localhost:8080}{Connection: keep-alive}
HttpURLConnection                    : sun.net.www.MessageHeader@3d34eab39 pairs: {null: HTTP/1.1 200 OK}{Referrer-Policy: no-referrer}{X-Frame-Options: SAMEORIGIN}{Strict-Transport-Security: max-age=31536000; includeSubDomains}{Cache-Control: no-cache}{X-Content-Type-Options: nosniff}{X-XSS-Protection: 1; mode=block}{Content-Type: application/json}{content-length: 132}
RestTemplate                         : Response 200 OK
RestTemplate                         : Reading to [java.util.Map<java.lang.String, java.lang.Object>]
HttpSessionSecurityContextRepository : Stored SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [0f0d7fe9-9293-4ef4-a476-9e2aba73028c], Granted Authorities: [[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]], User Attributes: [{at_hash=ZyqDs4SxHW5wDGuAMUpriA, sub=0f0d7fe9-9293-4ef4-a476-9e2aba73028c, email_verified=false, iss=http://localhost:8080/realms/master, typ=ID, preferred_username=harald, given_name=, nonce=RbvtU5VSAb_7XEClpkOzcvZCx_haCynK-ArhvVOmLJM, sid=656a1414-f9af-4312-9d49-e7746c757974, aud=[gigabank], acr=1, azp=gigabank, auth_time=2023-03-28T14:09:07Z, exp=2023-03-28T14:10:07Z, session_state=656a1414-f9af-4312-9d49-e7746c757974, family_name=, iat=2023-03-28T14:09:07Z, jti=6b35b395-a159-435e-aa89-d3c12bbb92d5}], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=02A08225735D5D44D7B70B2B996B6298], Granted Authorities=[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]]] to HttpSession [org.apache.catalina.session.StandardSessionFacade@3f7daeac]
OAuth2LoginAuthenticationFilter      : Set SecurityContextHolder to OAuth2AuthenticationToken [Principal=Name: [0f0d7fe9-9293-4ef4-a476-9e2aba73028c], Granted Authorities: [[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]], User Attributes: [{at_hash=ZyqDs4SxHW5wDGuAMUpriA, sub=0f0d7fe9-9293-4ef4-a476-9e2aba73028c, email_verified=false, iss=http://localhost:8080/realms/master, typ=ID, preferred_username=harald, given_name=, nonce=RbvtU5VSAb_7XEClpkOzcvZCx_haCynK-ArhvVOmLJM, sid=656a1414-f9af-4312-9d49-e7746c757974, aud=[gigabank], acr=1, azp=gigabank, auth_time=2023-03-28T14:09:07Z, exp=2023-03-28T14:10:07Z, session_state=656a1414-f9af-4312-9d49-e7746c757974, family_name=, iat=2023-03-28T14:09:07Z, jti=6b35b395-a159-435e-aa89-d3c12bbb92d5}], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=02A08225735D5D44D7B70B2B996B6298], Granted Authorities=[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]]
DefaultRedirectStrategy              : Redirecting to http://localhost:8888/?continue
HttpSessionSecurityContextRepository : Retrieved SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [0f0d7fe9-9293-4ef4-a476-9e2aba73028c], Granted Authorities: [[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]], User Attributes: [{at_hash=ZyqDs4SxHW5wDGuAMUpriA, sub=0f0d7fe9-9293-4ef4-a476-9e2aba73028c, email_verified=false, iss=http://localhost:8080/realms/master, typ=ID, preferred_username=harald, given_name=, nonce=RbvtU5VSAb_7XEClpkOzcvZCx_haCynK-ArhvVOmLJM, sid=656a1414-f9af-4312-9d49-e7746c757974, aud=[gigabank], acr=1, azp=gigabank, auth_time=2023-03-28T14:09:07Z, exp=2023-03-28T14:10:07Z, session_state=656a1414-f9af-4312-9d49-e7746c757974, family_name=, iat=2023-03-28T14:09:07Z, jti=6b35b395-a159-435e-aa89-d3c12bbb92d5}], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=02A08225735D5D44D7B70B2B996B6298], Granted Authorities=[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]]]
FilterChainProxy                     : Secured GET /?continue

Logovanie potrebuje:

logging.level.sun.net.www.protocol.http.HttpURLConnection=DEBUG
logging.level.org.springframework.web.client.RestTemplate=DEBUG
logging.level.org.springframework.security=DEBUG

Použitie tokenu v Spring Boote

Token získaný knižnicou Spring OAuth 2 Client je iný než v prípade knižnice Spring OAuth 2 Resource Server. Namiesto objektu typu Jwt získame principal typu org.springframework.security.oauth2.core.oidc.user.OidcUser.

@GetMapping("/accounts/{accountId}/balance")
public BigDecimal getBalance(@PathVariable String accountId,
                             @AuthenticationPrincipal OidcUser oicUser) { (1)
    String userId = oicUser.getPreferredUsername(); (2)
    logger.info("Retrieving bank account balance: account: {}, user {}", accountId, userId);
    return BigDecimal.TEN;
}
1 Objekt principal je typu OidcUser.
2 Namiesto claimov, ktoré sú uložené v mape, vieme používať priamo konkrétne metódy pre prístup k najdôležitejším údajom.

Roly v Spring Boote

Vo flowe ROPC sa roly publikovali do claimu realm_access (pre roly z realmu), resp. resource_access (pre roly z klienta).

Ak chceme publikovať roly do tokenu vo flowe Authorization Code, musíme ich prispôsobiť, keďže Spring Boot vyťahuje roly z identifikačného tokenu (ID Token), resp. z výsledkov volania endpointu user_info v Keycloaku. Ani jeden z týchto spôsobov roly neobsahuje.

Prispôsobme si to:

  • pridajme nové mapovanie medzi rolami a claimom v tokene

  • upravme Spring Boot, aby dokázal roly vytiahnuť.

Mapovanie medzi rolami a claimom tokenu

  1. V ľavom menu vyberme Clients, a zvoľme klienta gigabank.

  2. Na karte Client Scopes vyberme klientsky scope gigabank-dedicated, ktorý obsahuje mapovania do claimov pre klienta gigabank.

  3. Dodajme nový preddefinovaný mapovač cez Add predefined mapper.

  4. Zobrazí sa zoznam, kde sa musíme cez stránkovanie prepracovať k mapovaču pre roly realmu: realm roles.

  5. Pridajme ho do zoznamu, ale následne ho cez hypertextový odkaz prispôsobíme.

    mapper details
    1. Nastavme názov claimu v tokene: Token Claim Name. Ak uvedieme realm_roles, v tokene sa objaví claim s týmto menom.

    2. Tento claim pridajme do identifikačného tokenu (ID token), keďže z neho bude Spring Boot načítavať údaje o autorizovanom používateľovi.

Mapovanie vieme overiť cez Keycloak. . V ľavom menu vyberme Clients, a zvoľme klienta gigabank. . Na karte Client Scopes vyberme kartu Evaluate. . Vyberme používateľa harald.

evaluate scope

Následne si vpravo vieme prezrieť generované tokeny: ale nás zaujíma len Generated ID token. V každom bloku uvidíme claim realm_roles s rolami realmu.

{
  "exp": 1680097008,
  "iat": 1680096948,
  "preferred_username": "harald",
  ...
  "realm_roles": [
    "default-roles-master",
    "offline_access",
    "creditor",
    "uma_authorization"
  ]
}

Mapovanie v Springu

Modul Spring OAuth 2 Client potrebujeme prispôsobiť, aby vedel pristúpiť k rolám z tokenu.

Štandardne sa totiž sprístupňujú len roly mapované na scopes z OAuth.

Využijeme existujúcu triedu OidcUserService, z ktorej vytiahneme existujúce roly, následne dotiahneme roly z claimu realm_roles a všetko dáme dohromady.

RealmRoleUserService.java
public class RealmRoleUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
    public static final String REALM_ROLES_CLAIM = "realm_roles";

    private String realmRolesClaim = REALM_ROLES_CLAIM;

    private final OidcUserService delegateUserService = new OidcUserService(); (1)

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = this.delegateUserService.loadUser(userRequest); (2)

        Set<GrantedAuthority> allAuthorities = new HashSet<>(oidcUser.getAuthorities());  (3)
        oidcUser.getClaimAsStringList(this.realmRolesClaim) (4)
                .stream()
                .map(SimpleGrantedAuthority::new)
                .forEach(allAuthorities::add);
        return new DefaultOidcUser(allAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
    }
}
1 Pripravíme si delegáta, ktorý vyrieši štandardné spracovanie tokenu.
2 Vytiahneme používateľa z OIDC tokenu.
3 Prevezmeme roly zo scopes.
4 Vytiahneme roly z claimu realm_roles.

Triedu potom dodáme do aplikačného kontextu ako bean, aby ju dohľadal Spring Security.

@Bean
OAuth2UserService<OidcUserRequest, OidcUser> oAuth2UserService() {
    return new RealmRoleUserService();
}

Následne môžeme používať autorizačné kontroly, napríklad:

@PreAuthorize("hasAuthority('creditor')")

Celý flow

Celý flow s volaním endpointov medzi používateľským prehliadačom, Keycloakom a aplikáciou v Spring Boote je na nasledovnom obrázku.

keycloak spring authorization code flow

Repozitár na GitHube

Zdrojové kódy sú k dispozícii na GitHube, v repozitári novotnyr/bank-restapi-oidc-authorization-code.

>> Home