Como habrás podido comprobar en mis anteriores entradas del blog, soy un gran fan de Spring + Java y Spring + Kotlin. En consecuencia, siempre que necesito implementar una autenticación OAuth 2.0, la librería spring-security-oauth2 es una elección natural.
Sin embargo, no hay casi nada que muestre cómo unir Spring Security y OAuth2 - conectando spring-seguridad-oauth2 con diferentes fuentes de datos que no sean inMemory y JDBC. Como tenemos que configurar un montón de cosas, voy a dividir este tutorial en 3 partes: Cómo autenticar un usuario, cómo configurar un almacén de fichas y cómo configurar clientes dinámicos. Así que, ¡manos a la obra!
En primer lugar, supongo que está utilizando una de las últimas versiones de spring-seguridad-oauth2:
|
1 2 3 4 5 |
<dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-seguridad-oauth2</artifactId> <version>2.3.3.RELEASE</version> </dependency> |
En segundo lugar, estoy usando Couchbase con Spring Data. Si utilizas cualquier otra fuente de datos, puedes reutilizar gran parte del código de este blog.
|
1 2 3 4 5 |
<dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-couchbase</artifactId> <version>3.0.5.RELEASE</version> </dependency> |
Además, he añadido Lombok como dependencia para reducir el boilerplate de Java:
|
1 2 3 4 5 |
<dependencia> <groupId>org.proyectolombok</groupId> <artifactId>lombok</artifactId> <opcional>verdadero</opcional> </dependencia> |
Vamos a configurar nuestro Servidor de Recursos, de acuerdo con la documentación de Spring Security relativa a spring-seguridad-oauth2: "Un Servidor de Recursos (puede ser el mismo que el Servidor de Autorización o una aplicación separada) sirve recursos que están protegidos por el token OAuth2. Spring OAuth proporciona un filtro de autenticación Spring Security que implementa esta protección. Puede activarlo con @HabilitarServidorDeRecursos en un @Configuración y configurarlo (según sea necesario) utilizando un ResourceServerConfigurer".
|
1 2 3 4 5 6 7 8 9 10 11 |
@Configuración @HabilitarServidorDeRecursos público clase ResourceServerConfig extiende AdaptadorConfiguradorDeServidorDeRecursos { privado estático final Cadena RESOURCE_ID = "resource_id"; @Override público void configure(Configurador de seguridad del servidor de recursos recursos) { recursos.resourceId(RESOURCE_ID).sin estado(falso); } } |
Ahora, implementemos una interfaz llamada ServicioDeDetallesDelUsuario. Es la interfaz responsable de ser el puente entre su fuente de datos y Spring Security OAuth:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
importar com.bc.tarea rápida.independiente.modelo.CustomUserDetail; importar com.bc.tarea rápida.independiente.modelo.Grupo de seguridad; importar com.bc.tarea rápida.independiente.modelo.Usuario; importar com.bc.tarea rápida.independiente.repositorio.UsuarioRepositorio; importar lombok.externo.slf4j.Slf4j; importar org.springframework.judías.fábrica.anotación.Autowired; importar org.springframework.seguridad.núcleo.detalles de usuario.DetallesDelUsuario; importar org.springframework.seguridad.núcleo.detalles de usuario.ServicioDeDetallesDelUsuario; importar org.springframework.seguridad.núcleo.detalles de usuario.UsernameNotFoundException; importar org.springframework.estereotipo.Servicio; importar java.util.Lista; importar java.util.flujo.Coleccionistas; @Slf4j @Servicio público clase CustomUserDetailsService implementa ServicioDeDetallesDelUsuario { @Autowired privado UsuarioRepositorio usuarioRepositorio; @Autowired privado SecurityGroupService securityGroupService; @Override público DetallesDelUsuario loadUserByUsername(Cadena nombre) lanza UsernameNotFoundException { Lista<User> usuarios = usuarioRepositorio.findByUsername(nombre); si(usuarios.isEmpty()) { tirar nuevo UsernameNotFoundException("No se pudo encontrar el usuario "+nombre); } Usuario usuario = usuarios.consiga(0); Lista<SecurityGroup> securityGroups = securityGroupService.listGruposUsuario(usuario.getCompanyId(), usuario.getId()); devolver nuevo CustomUserDetail(usuario, securityGroups.flujo() .mapa(e->e.getId()) .recoja(Coleccionistas.toList()) ); } } |
En el código anterior, estamos devolviendo una clase del tipo DetallesDelUsuarioque también es de Spring. Aquí está su implementación:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
Datos público clase CustomUserDetail implementa DetallesDelUsuario { privado Usuario usuario; privado Lista<String> grupos; público CustomUserDetail(Usuario usuario, Lista<String> grupos) { este.usuario = usuario; este.grupos = grupos; } @Override público Colección<? extiende Autorización concedida> getAuthorities() { devolver null; } @Override público Cadena getPassword() { devolver usuario.getPassword(); } @Override público Cadena getUsername() { devolver usuario.getUsername(); } @Override público booleano isAccountNonExpired() { devolver verdadero; } @Override público booleano isAccountNonLocked() { devolver verdadero; } @Override público booleano isCredentialsNonExpired() { devolver verdadero; } @Override público booleano isEnabled() { devolver usuario.getIsEnabled(); } } |
Podría haber hecho que la clase User implementara directamente UserDetails. Sin embargo, como mi caso de uso también requiere la lista de grupos en los que se encuentra el usuario, he añadido la implementación anterior.
Este es el aspecto de User, SecurityGroup y sus respectivos repositorios:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Datos público clase Usuario extiende Entidad básica implementa Serializable { @Id @NotNull privado Cadena id; @Campo @NotNull privado Cadena nombre de usuario; @Campo @NotNull privado Cadena companyId; @Campo @NotNull privado Cadena contraseña; @NotNull privado Booleano isEnabled; @Campo privado Booleano isVisible; } |
|
1 2 3 4 5 6 7 |
@N1qlPrimaryIndexed @VerIndexado(designDoc = "usuario") público interfaz UsuarioRepositorio extiende CouchbasePagingAndSortingRepository<Usuario, Cadena> { Lista<Usuario> findByUsername(Cadena nombre de usuario); } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Documento @Datos @NoArgsConstructor @Constructor público clase Grupo de seguridad extiende Entidad básica implementa Serializable { @Id privado Cadena id; @NotNull @Campo privado Cadena nombre; @Campo privado Cadena descripción; @NotNull @Campo privado Cadena companyId; @Campo privado Lista<String> usuarios = nuevo ArrayList<>(); @Campo privado booleano eliminado = falso; } |
|
1 2 3 4 5 6 7 8 9 10 |
@N1qlPrimaryIndexed @VerIndexado(designDoc = "grupo de seguridad") público interfaz SecurityGroupRepository extiende CouchbasePagingAndSortingRepository<Grupo de seguridad, Cadena> { @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and companyId = $1 and removed = false " + " AND ARRAY_CONTAINS(usuarios, $2) ") Lista<SecurityGroup> listGruposUsuario(Cadena companyId, Cadena userId); } |
En Entidad básica es también un pequeño hack para trabajar mejor con Spring Data y Couchbase:
|
1 2 3 4 5 6 7 |
público clase Entidad básica { @Getter(PROTEGIDO) @Setter(PROTEGIDO) @Ignorar protegido Cadena Clase; } |
Finalmente, aquí está la implementación de nuestra clase SecurityConfig:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
@Configuración @EnableWebMvc público clase SecurityConfig extiende WebSecurityConfigurerAdapter { @Autowired privado CustomUserDetailsService customUserDetailsService; @Bean @Override público AuthenticationManager authenticationManagerBean() lanza Excepción { devolver super.authenticationManagerBean(); } @Autowired público void globalUserDetails(AuthenticationManagerBuilder auth) lanza Excepción { auth.userDetailsService(customUserDetailsService) .codificador de contraseñas(codificador()); } @Override público void configure( WebSecurity web ) lanza Excepción { web.ignorando().antMatchers( HttpMétodo.OPCIONES, "/**" ); } @Override protegido void configure(HttpSeguridad http) lanza Excepción { http .csrf().desactivar() .autorizarSolicitudes() .antMatchers("/oauth/token").permitTodos() .antMatchers("/api-docs/**").permitTodos() .cualquierPetición().autentificado() .y().anónimo().desactivar(); } @Bean público TokenStore almacén de fichas() { devolver nuevo InMemoryTokenStore(); } @Bean público Codificador de contraseñas codificador(){ devolver NoOpPasswordEncoder.getInstance(); } @Bean público FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource fuente = nuevo UrlBasedCorsConfigurationSource(); CorsConfiguration config = nuevo CorsConfiguration(); config.setAllowCredentials(verdadero); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); fuente.registerCorsConfiguration("/**", config); FilterRegistrationBean judía = nuevo FilterRegistrationBean(nuevo CorsFilter(fuente)); judía.setOrder(0); devolver judía; } } |
En Spring-Boot 2.0 ya no podemos inyectar directamente el bean AuthenticationManager, pero sigue siendo necesario para el framework Spring Security. Por lo tanto, tenemos que implementar un pequeño hack con el fin de obtener acceso a este objeto:
|
1 2 3 4 5 |
@Bean @Override público AuthenticationManager authenticationManagerBean() lanza Excepción { devolver super.authenticationManagerBean(); } |
Vamos a dividir esta clase en pequeños trozos para entender lo que está pasando:
|
1 2 3 4 |
@Bean público Codificador de contraseñas codificador(){ devolver NoOpPasswordEncoder.getInstance(); } |
La contraseña de mi usuario está en texto plano, así que simplemente devuelvo una nueva instancia de NoOpPasswordEncoder. Un estándar común es devolver una instancia de la clase BCryptPasswordEncoder.
|
1 2 3 4 |
@Judía público TokenStore almacén de fichas() { devolver nuevo InMemoryTokenStore(); } |
Por ahora, vamos a utilizar un almacén de tokens en memoria, veremos en la parte 2 cómo utilizar también Couchbase como almacén de tokens.
|
1 2 3 4 5 |
@Autowired público void globalUserDetails(AuthenticationManagerBuilder auth) lanza Excepción { auth.userDetailsService(customUserDetailsService) .codificador de contraseñas(codificador()); } |
Aquí es donde ocurre la magia con OAuth2 y Spring Boot. Específicamente, le estamos diciendo a Spring que use nuestro CustomUserDetailsService para buscar usuarios. Este bloque de código es la parte central de lo que hemos hecho hasta ahora.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Bean público FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource fuente = nuevo UrlBasedCorsConfigurationSource(); CorsConfiguration config = nuevo CorsConfiguration(); config.setAllowCredentials(verdadero); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); fuente.registerCorsConfiguration("/**", config); FilterRegistrationBean judía = nuevo FilterRegistrationBean(nuevo CorsFilter(fuente)); judía.setOrder(0); devolver judía; } |
Este bloque nos permitirá realizar peticiones utilizando CORS (Cross-Origin Resource Sharing)
|
1 2 3 4 |
@Override público void configure( WebSecurity web ) lanza Excepción { web.ignorando().antMatchers( HttpMétodo.OPCIONES, "/**" ); } |
Y por último, si necesitas llamar a tu API a través de JQuery, también necesitas añadir el código anterior. De lo contrario, obtendrá un mensaje "La respuesta para la verificación previa no tiene el estado HTTP ok." Error.
Ahora sólo queda una cosa, necesitamos añadir un Servidor de Autorización:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
importar org.springframework.judías.fábrica.anotación.Autowired; importar org.springframework.contexto.anotación.Configuración; importar org.springframework.seguridad.autenticación.AuthenticationManager; importar org.springframework.seguridad.oauth2.config.anotación.configuradores.ConfiguradorDeDetallesDelCliente; importar org.springframework.seguridad.oauth2.config.anotación.web.configuración.AuthorizationServerConfigurerAdapter; importar org.springframework.seguridad.oauth2.config.anotación.web.configuración.EnableAuthorizationServer; importar org.springframework.seguridad.oauth2.config.anotación.web.configuradores.AuthorizationServerEndpointsConfigurer; importar org.springframework.seguridad.oauth2.proveedor.ficha.TokenStore; @Configuración @ActivarServidorAutorización público clase AuthorizationServerConfig extiende AuthorizationServerConfigurerAdapter { estático final Cadena CLIENTE_ID = "android-cliente"; estático final Cadena SECRETO_CLIENTE = "android-secret"; estático final Cadena GRANT_TYPE_PASSWORD = "contraseña"; estático final Cadena CÓDIGO_AUTORIZACIÓN = "authorization_code"; estático final Cadena REFRESH_TOKEN = "refresh_token"; estático final Cadena IMPLÍCITO = "implícito"; estático final Cadena ALCANCE_LEER = "leer"; estático final Cadena SCOPE_WRITE = "escribir"; estático final Cadena CONFÍE EN = "confianza"; estático final int ACCESS_TOKEN_VALIDITY_SECONDS = 1*60*60; estático final int REFRESH_TOKEN_VALIDITY_SECONDS = 6*60*60; @Autowired privado TokenStore almacén de fichas; @Autowired privado AuthenticationManager authenticationManager; @Override público void configure(ConfiguradorDeDetallesDelCliente configurador) lanza Excepción { configurador .enMemoria() .conCliente(CLIENTE_ID) .secreto(SECRETO_CLIENTE) .authorizedGrantTypes(GRANT_TYPE_PASSWORD, CÓDIGO_AUTORIZACIÓN, REFRESH_TOKEN, IMPLÍCITO ) .ámbitos(ALCANCE_LEER, SCOPE_WRITE, CONFÍE EN) .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY_SECONDS). refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY_SECONDS); } @Override público void configure(AuthorizationServerEndpointsConfigurer puntos finales) lanza Excepción { puntos finales.almacén de fichas(almacén de fichas) .authenticationManager(authenticationManager); } @Override público void configure(AuthorizationServerSecurityConfigurer oauthServer) lanza Excepción { oauthServer.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } } |
Bien hecho, ahora puedes iniciar tu aplicación y llamarla a través de Postman o Jquery:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
var datos = { "tipo_concesión": "contraseña", "nombre de usuario": "miusuario", "contraseña":"micontraseña", "client_id":"android-cliente", "secreto_cliente":"android-secret" } $.ajax({ url: "http://localhost:8080/oauth/token", tipo: POST, "crossDomain": verdadero, "cabeceras": { Autorización: 'Basic YW5kcm9pZC1jbGllbnQ6YW5kcm9pZC1zZWNyZXQ=', //android-client:android-secret en Base64 Tipo de contenido:'application/x-www-form-urlencoded'}, "datos":datos, éxito: función (resultado) { consola.registro( "Mi código de acceso = "+ resultado.código_de_acceso); consola.registro( "Mi token de actualización = "+ resultado.refresh_token); consola.registro("caduca en = "+resultado.caduca_en) succesCallback() }, error: función (XMLHttpRequest, textStatus, errorThrown) { errorCallback(XMLHttpRequest, textStatus, errorThrown) } }); |
Aumentar el rendimiento
Si utilizas Couchbase, te sugiero que utilices el nombre de usuario como clave de tu documento. Esto le permitirá utilizar el Almacén clave-valor en lugar de ejecutar consultas N1QL, lo que aumentará significativamente el rendimiento de su inicio de sesión.
Si tienes alguna pregunta sobre Couchbase, seguridad OAuth u optimización de Spring y autenticación OAuth2, envíame un tweet a @deniswsrosa
Muy buen artículo. ¿Podría compartir el código de este post?