API Spring REST + OAuth2 + Angular

1. Vue d'ensemble

Dans ce tutoriel, nous sécuriserons une API REST avec OAuth2 et la consommerons à partir d'un simple client Angular.

L'application que nous allons développer sera composée de trois modules distincts:

  • Serveur d'autorisation
  • Serveur de ressources
  • Code d'autorisation de l'interface utilisateur: une application frontale utilisant le flux de code d'autorisation

Nous utiliserons la pile OAuth dans Spring Security 5. Si vous souhaitez utiliser la pile héritée Spring Security OAuth, consultez cet article précédent: API Spring REST + OAuth2 + Angular (à l'aide de la pile héritée Spring Security OAuth).

Allons-y.

2. Le serveur d’autorisation OAuth2 (AS)

En termes simples, un serveur d'autorisation est une application qui émet des jetons d'autorisation.

Auparavant, la pile Spring Security OAuth offrait la possibilité de configurer un serveur d'autorisation en tant qu'application Spring. Mais le projet a été abandonné, principalement parce qu'OAuth est un standard ouvert avec de nombreux fournisseurs bien établis tels que Okta, Keycloak et ForgeRock, pour n'en nommer que quelques-uns.

Parmi ceux-ci, nous utiliserons Keycloak. Il s'agit d'un serveur Open Source de gestion des identités et des accès administré par Red Hat, développé en Java, par JBoss. Il prend en charge non seulement OAuth2 mais également d'autres protocoles standard tels que OpenID Connect et SAML.

Pour ce didacticiel, nous allons configurer un serveur Keycloak intégré dans une application Spring Boot.

3. Le serveur de ressources (RS)

Parlons maintenant du serveur de ressources; il s'agit essentiellement de l'API REST, que nous voulons finalement pouvoir consommer.

3.1. Configuration Maven

Le pom de notre serveur de ressources est sensiblement le même que le pom du serveur d'autorisation précédent, sans la partie Keycloak et avec une dépendance supplémentaire spring-boot-starter-oauth2-resource-server :

 org.springframework.boot     spring-boot-starter-oauth2-resource-server 

3.2. Configuration de la sécurité

Puisque nous utilisons Spring Boot, nous pouvons définir la configuration minimale requise à l'aide des propriétés de démarrage.

Nous allons le faire dans un fichier application.yml :

server: port: 8081 servlet: context-path: /resource-server spring: security: oauth2: resourceserver: jwt: issuer-uri: //localhost:8083/auth/realms/baeldung jwk-set-uri: //localhost:8083/auth/realms/baeldung/protocol/openid-connect/certs

Ici, nous avons spécifié que nous utiliserons des jetons JWT pour l'autorisation.

La propriété jwk-set-uri pointe vers l'URI contenant la clé publique afin que notre serveur de ressources puisse vérifier l'intégrité des jetons.

La propriété issuer-uri représente une mesure de sécurité supplémentaire pour valider l'émetteur des jetons (qui est le serveur d'autorisation). Cependant, l'ajout de cette propriété exige également que le serveur d'autorisation soit en cours d'exécution avant de pouvoir démarrer l'application Resource Server.

Ensuite, configurons une configuration de sécurité pour l'API afin de sécuriser les points de terminaison :

@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.cors() .and() .authorizeRequests() .antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**") .hasAuthority("SCOPE_read") .antMatchers(HttpMethod.POST, "/api/foos") .hasAuthority("SCOPE_write") .anyRequest() .authenticated() .and() .oauth2ResourceServer() .jwt(); } }

Comme nous pouvons le voir, pour nos méthodes GET, nous n'autorisons que les requêtes qui ont une portée de lecture . Pour la méthode POST, le demandeur doit avoir une autorisation d' écriture en plus de read . Cependant, pour tout autre point de terminaison, la demande doit simplement être authentifiée auprès de n'importe quel utilisateur.

De plus, la méthode oauth2ResourceServer () spécifie qu'il s'agit d'un serveur de ressources, avec des jetons au format jwt () .

Un autre point à noter ici est l'utilisation de la méthode cors () pour autoriser les en-têtes de contrôle d'accès sur les requêtes. Ceci est d'autant plus important que nous avons affaire à un client Angular et que nos requêtes vont provenir d'une autre URL d'origine.

3.4. Le modèle et le référentiel

Ensuite, définissons un javax.persistence.Entity pour notre modèle, Foo :

@Entity public class Foo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; // constructor, getters and setters }

Ensuite, nous avons besoin d'un référentiel de Foo s. Nous utiliserons PagingAndSortingRepository de Spring :

public interface IFooRepository extends PagingAndSortingRepository { } 

3.4. Le service et la mise en œuvre

Après cela, nous définirons et implémenterons un service simple pour notre API:

public interface IFooService { Optional findById(Long id); Foo save(Foo foo); Iterable findAll(); } @Service public class FooServiceImpl implements IFooService { private IFooRepository fooRepository; public FooServiceImpl(IFooRepository fooRepository) { this.fooRepository = fooRepository; } @Override public Optional findById(Long id) { return fooRepository.findById(id); } @Override public Foo save(Foo foo) { return fooRepository.save(foo); } @Override public Iterable findAll() { return fooRepository.findAll(); } } 

3.5. Un contrôleur d'échantillons

Maintenant, implémentons un contrôleur simple exposant notre ressource Foo via un DTO:

@RestController @RequestMapping(value = "/api/foos") public class FooController { private IFooService fooService; public FooController(IFooService fooService) { this.fooService = fooService; } @CrossOrigin(origins = "//localhost:8089") @GetMapping(value = "/{id}") public FooDto findOne(@PathVariable Long id) { Foo entity = fooService.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); return convertToDto(entity); } @GetMapping public Collection findAll() { Iterable foos = this.fooService.findAll(); List fooDtos = new ArrayList(); foos.forEach(p -> fooDtos.add(convertToDto(p))); return fooDtos; } protected FooDto convertToDto(Foo entity) { FooDto dto = new FooDto(entity.getId(), entity.getName()); return dto; } }

Notez l'utilisation de @CrossOrigin ci-dessus; c'est la configuration au niveau du contrôleur dont nous avons besoin pour autoriser CORS à partir de notre application angulaire à s'exécuter à l'URL spécifiée.

Voici notre FooDto :

public class FooDto { private long id; private String name; }

4. Front End - Configuration

Nous allons maintenant examiner une implémentation angulaire frontale simple pour le client, qui accédera à notre API REST.

Nous allons d'abord utiliser Angular CLI pour générer et gérer nos modules frontaux.

Tout d'abord, nous installons node et npm , car Angular CLI est un outil npm.

Ensuite, nous devons utiliser le frontend-maven-plugin pour construire notre projet Angular en utilisant Maven:

   com.github.eirslett frontend-maven-plugin 1.3  v6.10.2 3.10.10 src/main/resources    install node and npm  install-node-and-npm    npm install  npm    npm run build  npm   run build      

Et enfin, générez un nouveau module en utilisant Angular CLI:

ng new oauthApp

Dans la section suivante, nous aborderons la logique de l'application Angular.

5. Flux de code d'autorisation utilisant Angular

Nous allons utiliser le flux de code d'autorisation OAuth2 ici.

Notre cas d'utilisation: l'application client demande un code au serveur d'autorisation et est présentée avec une page de connexion. Une fois qu'un utilisateur fournit ses informations d'identification valides et soumet, le serveur d'autorisation nous donne le code. Ensuite, le client frontal l'utilise pour acquérir un jeton d'accès.

5.1. Composant maison

Commençons par notre composant principal, le HomeComponent , où toute l'action commence:

@Component({ selector: 'home-header', providers: [AppService], template: ` Login Welcome !! Logout

` }) export class HomeComponent { public isLoggedIn = false; constructor(private _service: AppService) { } ngOnInit() { this.isLoggedIn = this._service.checkCredentials(); let i = window.location.href.indexOf('code'); if(!this.isLoggedIn && i != -1) { this._service.retrieveToken(window.location.href.substring(i + 5)); } } login() { window.location.href = '//localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth? response_type=code&scope=openid%20write%20read&client_id=' + this._service.clientId + '&redirect_uri='+ this._service.redirectUri; } logout() { this._service.logout(); } }

Au début, lorsque l'utilisateur n'est pas connecté, seul le bouton de connexion apparaît. En cliquant sur ce bouton, l'utilisateur accède à l'URL d'autorisation de l'AS où il saisit son nom d'utilisateur et son mot de passe. Après une connexion réussie, l'utilisateur est redirigé avec le code d'autorisation, puis nous récupérons le jeton d'accès à l'aide de ce code.

5.2. Service d'application

Regardons maintenant AppService - situé sur app.service.ts - qui contient la logique des interactions avec le serveur:

  • retrieveToken () : pour obtenir un jeton d'accès à l'aide du code d'autorisation
  • saveToken () : pour enregistrer notre jeton d'accès dans un cookie en utilisant la bibliothèque ng2-cookies
  • getResource () : pour récupérer un objet Foo du serveur en utilisant son ID
  • checkCredentials () : pour vérifier si l'utilisateur est connecté ou non
  • logout () : pour supprimer le cookie du jeton d'accès et déconnecter l'utilisateur
export class Foo { constructor(public id: number, public name: string) { } } @Injectable() export class AppService { public clientId = 'newClient'; public redirectUri = '//localhost:8089/'; constructor(private _http: HttpClient) { } retrieveToken(code) { let params = new URLSearchParams(); params.append('grant_type','authorization_code'); params.append('client_id', this.clientId); params.append('client_secret', 'newClientSecret'); params.append('redirect_uri', this.redirectUri); params.append('code',code); let headers = new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'}); this._http.post('//localhost:8083/auth/realms/baeldung/protocol/openid-connect/token', params.toString(), { headers: headers }) .subscribe( data => this.saveToken(data), err => alert('Invalid Credentials')); } saveToken(token) { var expireDate = new Date().getTime() + (1000 * token.expires_in); Cookie.set("access_token", token.access_token, expireDate); console.log('Obtained Access token'); window.location.href = '//localhost:8089'; } getResource(resourceUrl) : Observable { var headers = new HttpHeaders({ 'Content-type': 'application/x-www-form-urlencoded; charset=utf-8', 'Authorization': 'Bearer '+Cookie.get('access_token')}); return this._http.get(resourceUrl, { headers: headers }) .catch((error:any) => Observable.throw(error.json().error || 'Server error')); } checkCredentials() { return Cookie.check('access_token'); } logout() { Cookie.delete('access_token'); window.location.reload(); } }

Dans la méthode retrieveToken , nous utilisons nos informations d'identification client et l'authentification de base pour envoyer un POST au point de terminaison / openid-connect / token afin d'obtenir le jeton d'accès. Les paramètres sont envoyés dans un format encodé en URL. Après avoir obtenu le jeton d'accès, nous le stockons dans un cookie.

Le stockage des cookies est particulièrement important ici car nous n'utilisons le cookie qu'à des fins de stockage et non pour piloter directement le processus d'authentification. Cela permet de se protéger contre les attaques et les vulnérabilités de falsification de requêtes intersites (CSRF).

5.3. Composant Foo

Enfin, notre FooComponent pour afficher nos détails Foo:

@Component({ selector: 'foo-details', providers: [AppService], template: `  ID {{foo.id}} Name {{foo.name}} New Foo ` }) export class FooComponent { public foo = new Foo(1,'sample foo'); private foosUrl = '//localhost:8081/resource-server/api/foos/'; constructor(private _service:AppService) {} getFoo() { this._service.getResource(this.foosUrl+this.foo.id) .subscribe( data => this.foo = data, error => this.foo.name = 'Error'); } }

5.5. Composant d'application

Notre simple AppComponent pour agir en tant que composant racine:

@Component({ selector: 'app-root', template: ` Spring Security Oauth - Authorization Code ` }) export class AppComponent { } 

Et l' AppModule où nous enveloppons tous nos composants, services et routes:

@NgModule({ declarations: [ AppComponent, HomeComponent, FooComponent ], imports: [ BrowserModule, HttpClientModule, RouterModule.forRoot([ { path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'}) ], providers: [], bootstrap: [AppComponent] }) export class AppModule { } 

7. Exécutez le frontal

1. Pour exécuter l'un de nos modules frontaux, nous devons d'abord créer l'application:

mvn clean install

2. Ensuite, nous devons accéder à notre répertoire d'applications Angular:

cd src/main/resources

3. Enfin, nous allons démarrer notre application:

npm start

Le serveur démarrera par défaut sur le port 4200; pour changer le port de n'importe quel module, changez:

"start": "ng serve"

dans package.json; par exemple, pour le faire fonctionner sur le port 8089, ajoutez:

"start": "ng serve --port 8089"

8. Conclusion

Dans cet article, nous avons appris à autoriser notre application à l'aide d'OAuth2.

L'implémentation complète de ce didacticiel se trouve dans le projet GitHub.