Spring REST API + OAuth2 + Angular

1. Información general

En este tutorial, aseguraremos una API REST con OAuth2 y la consumiremos desde un simple cliente Angular.

La aplicación que vamos a desarrollar constará de tres módulos separados:

  • Servidor de autorización
  • Servidor de recursos
  • Código de autorización de la interfaz de usuario: una aplicación front-end que utiliza el flujo del código de autorización

Usaremos la pila de OAuth en Spring Security 5. Si desea utilizar la pila heredada de Spring Security OAuth, eche un vistazo a este artículo anterior: Spring REST API + OAuth2 + Angular (usando la pila heredada de Spring Security OAuth).

Saltemos directamente.

2. El servidor de autorización OAuth2 (AS)

En pocas palabras, un servidor de autorización es una aplicación que emite tokens para su autorización.

Anteriormente, la pila Spring Security OAuth ofrecía la posibilidad de configurar un servidor de autorización como una aplicación Spring. Pero el proyecto ha quedado obsoleto, principalmente porque OAuth es un estándar abierto con muchos proveedores bien establecidos como Okta, Keycloak y ForgeRock, por nombrar algunos.

De estos, usaremos Keycloak. Es un servidor de gestión de acceso e identidad de código abierto administrado por Red Hat, desarrollado en Java por JBoss. Es compatible no solo con OAuth2, sino también con otros protocolos estándar como OpenID Connect y SAML.

Para este tutorial, configuraremos un servidor Keycloak integrado en una aplicación Spring Boot.

3. El servidor de recursos (RS)

Ahora analicemos el servidor de recursos; esta es esencialmente la API REST, que en última instancia queremos poder consumir.

3.1. Configuración de Maven

El pom de nuestro servidor de recursos es muy similar al pom del servidor de autorización anterior, sin la parte de Keycloak y con una dependencia adicional spring-boot-starter-oauth2-resource-server :

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

3.2. Configuración de seguridad

Dado que estamos usando Spring Boot, podemos definir la configuración mínima requerida usando las propiedades de Boot.

Haremos esto en un archivo 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

Aquí, especificamos que usaremos tokens JWT para la autorización.

La propiedad jwk-set-uri apunta al URI que contiene la clave pública para que nuestro servidor de recursos pueda verificar la integridad de los tokens.

La propiedad issuer-uri representa una medida de seguridad adicional para validar al emisor de los tokens (que es el servidor de autorización). Sin embargo, agregar esta propiedad también exige que el servidor de autorización se esté ejecutando antes de que podamos iniciar la aplicación del servidor de recursos.

A continuación, configuremos una configuración de seguridad para la API para proteger los puntos finales :

@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(); } }

Como podemos ver, para nuestros métodos GET, solo permitimos solicitudes que tengan alcance de lectura . Para el método POST, el solicitante debe tener autoridad de escritura además de lectura . Sin embargo, para cualquier otro punto final, la solicitud solo debe autenticarse con cualquier usuario.

Además, el método oauth2ResourceServer () especifica que este es un servidor de recursos, con tokens con formato jwt () .

Otro punto a tener en cuenta aquí es el uso del método cors () para permitir encabezados de control de acceso en las solicitudes. Esto es especialmente importante ya que estamos tratando con un cliente Angular y nuestras solicitudes provendrán de otra URL de origen.

3.4. El modelo y el repositorio

A continuación, definamos una entidad javax.persistence.Entity para nuestro modelo, Foo :

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

Entonces necesitamos un repositorio de Foo s. Usaremos PagingAndSortingRepository de Spring :

public interface IFooRepository extends PagingAndSortingRepository { } 

3.4. El servicio y la implementación

Después de eso, definiremos e implementaremos un servicio simple para nuestra 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 controlador de muestra

Ahora implementemos un controlador simple que exponga nuestro recurso Foo a través de 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; } }

Observe el uso de @CrossOrigin arriba; esta es la configuración a nivel de controlador que necesitamos para permitir que CORS desde nuestra aplicación angular se ejecute en la URL especificada.

Aquí está nuestro FooDto :

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

4. Interfaz - Configuración

Ahora veremos una implementación Angular de front-end simple para el cliente, que accederá a nuestra API REST.

Primero usaremos Angular CLI para generar y administrar nuestros módulos de front-end.

Primero, instalamos node y npm , ya que Angular CLI es una herramienta npm.

Luego, necesitamos usar el frontend-maven-plugin para construir nuestro proyecto Angular usando 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      

Y finalmente, genere un nuevo módulo usando Angular CLI:

ng new oauthApp

En la siguiente sección, discutiremos la lógica de la aplicación Angular.

5. Flujo del código de autorización usando Angular

Vamos a utilizar el flujo del código de autorización OAuth2 aquí.

Nuestro caso de uso: la aplicación cliente solicita un código del servidor de autorización y se le presenta una página de inicio de sesión. Una vez que un usuario proporciona sus credenciales válidas y las envía, el servidor de autorización nos da el código. Luego, el cliente de front-end lo usa para adquirir un token de acceso.

5.1. Componente de la casa

Comencemos con nuestro componente principal, el HomeComponent , donde comienza toda la acción:

@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(); } }

Al principio, cuando el usuario no ha iniciado sesión, solo aparece el botón de inicio de sesión. Al hacer clic en este botón, el usuario es dirigido a la URL de autorización del AS donde ingresa el nombre de usuario y la contraseña. Después de un inicio de sesión exitoso, el usuario es redirigido con el código de autorización y luego recuperamos el token de acceso usando este código.

5.2. Servicio de aplicaciones

Ahora veamos AppService , ubicado en app.service.ts , que contiene la lógica para las interacciones del servidor:

  • retrieveToken () : para obtener el token de acceso usando el código de autorización
  • saveToken () : para guardar nuestro token de acceso en una cookie usando la biblioteca ng2-cookies
  • getResource () : para obtener un objeto Foo del servidor usando su ID
  • checkCredentials () : para verificar si el usuario está conectado o no
  • logout () : para eliminar la cookie del token de acceso y cerrar la sesión del usuario
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(); } }

En el método retrieveToken , usamos nuestras credenciales de cliente y la autenticación básica para enviar una POST al punto final / openid-connect / token para obtener el token de acceso. Los parámetros se envían en formato de codificación URL. Después de obtener el token de acceso, lo almacenamos en una cookie.

El almacenamiento de cookies es especialmente importante aquí porque solo usamos la cookie con fines de almacenamiento y no para impulsar el proceso de autenticación directamente. Esto ayuda a proteger contra ataques y vulnerabilidades de falsificación de solicitudes entre sitios (CSRF).

5.3. Componente Foo

Finalmente, nuestro FooComponent para mostrar nuestros detalles de 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. Componente de la aplicación

Nuestro AppComponent simple para actuar como el componente raíz:

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

Y el AppModule donde envolvemos todos nuestros componentes, servicios y rutas:

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

7. Ejecute la interfaz

1. Para ejecutar cualquiera de nuestros módulos frontales, primero debemos compilar la aplicación:

mvn clean install

2. Luego tenemos que navegar a nuestro directorio de aplicaciones Angular:

cd src/main/resources

3. Finalmente, iniciaremos nuestra aplicación:

npm start

El servidor se iniciará de forma predeterminada en el puerto 4200; para cambiar el puerto de cualquier módulo, cambie:

"start": "ng serve"

en package.json; por ejemplo, para que se ejecute en el puerto 8089, agregue:

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

8. Conclusión

En este artículo, aprendimos cómo autorizar nuestra aplicación mediante OAuth2.

La implementación completa de este tutorial se puede encontrar en el proyecto GitHub.