Como você deve ter notado em meus posts anteriores, sou um grande fã de Spring + Java e Primavera + Kotlin. Consequentemente, sempre que preciso implementar uma autenticação OAuth 2.0, a biblioteca spring-security-oauth2 é uma escolha natural.
No entanto, não há quase nada no mercado que mostre como unir o Spring Security e o OAuth2 - conectando o segurança de primavera-oauth2 com diferentes fontes de dados além de inMemory e JDBC. Como temos que configurar muitas coisas, dividirei este tutorial em três partes: Como autenticar um usuário, como configurar um armazenamento de tokens e como configurar clientes dinâmicos. Então, vamos começar!
Primeiro, presumo que você esteja usando uma das versões mais recentes do segurança de primavera-oauth2:
|
1 2 3 4 5 |
<dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.3.RELEASE</version> </dependency> |
Em segundo lugar, estou usando o Couchbase com o Spring Data. Se você estiver usando qualquer outra fonte de dados, ainda poderá reutilizar muitos códigos desta série do blog.
|
1 2 3 4 5 |
<dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-couchbase</artifactId> <version>3.0.5.RELEASE</version> </dependency> |
Além disso, adicionei Lombok como uma dependência para reduzir o boilerplate do Java:
|
1 2 3 4 5 |
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> |
Vamos configurar nosso Resource Server, de acordo com a documentação do Spring Security relacionada a segurança de primavera-oauth2: "Um Servidor de Recursos (pode ser o mesmo que o Servidor de Autorização ou um aplicativo separado) serve recursos que são protegidos pelo token OAuth2. O Spring OAuth fornece um filtro de autenticação do Spring Security que implementa essa proteção. Você pode ativá-lo com @EnableResourceServer em um @Configuração e configurá-lo (conforme necessário) usando um ResourceServerConfigurer"
|
1 2 3 4 5 6 7 8 9 10 11 |
@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { private static final String RESOURCE_ID = "resource_id"; @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId(RESOURCE_ID).stateless(false); } } |
Agora, vamos implementar uma interface chamada UserDetailsService. É a interface responsável por fazer a ponte entre sua fonte de dados e o 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 |
import com.bc.quicktask.standalone.model.CustomUserDetail; import com.bc.quicktask.standalone.model.SecurityGroup; import com.bc.quicktask.standalone.model.User; import com.bc.quicktask.standalone.repository.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Slf4j @Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Autowired private SecurityGroupService securityGroupService; @Override public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException { List<User> users = userRepository.findByUsername(name); if(users.isEmpty()) { throw new UsernameNotFoundException("Could not find the user "+name); } User user = users.get(0); List<SecurityGroup> securityGroups = securityGroupService.listUserGroups(user.getCompanyId(), user.getId()); return new CustomUserDetail(user, securityGroups.stream() .map(e->e.getId()) .collect(Collectors.toList()) ); } } |
No código acima, estamos retornando uma classe do tipo UserDetailsque também é do Spring. Aqui está sua implementação:
|
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 |
Data public class CustomUserDetail implements UserDetails { private User user; private List<String> groups; public CustomUserDetail(User user, List<String> groups) { this.user = user; this.groups = groups; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return user.getIsEnabled(); } } |
Eu poderia ter feito com que a classe User implementasse o UserDetails diretamente. No entanto, como meu caso de uso também exige a lista de grupos em que o usuário está, adicionei a implementação acima.
Veja a seguir a aparência do usuário, do SecurityGroup e de seus respectivos repositórios:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Data public class User extends BasicEntity implements Serializable { @Id @NotNull private String id; @Field @NotNull private String username; @Field @NotNull private String companyId; @Field @NotNull private String password; @NotNull private Boolean isEnabled; @Field private Boolean isVisible; } |
|
1 2 3 4 5 6 7 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "user") public interface UserRepository extends CouchbasePagingAndSortingRepository<User, String> { List<User> findByUsername(String username); } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Document @Data @NoArgsConstructor @Builder public class SecurityGroup extends BasicEntity implements Serializable { @Id private String id; @NotNull @Field private String name; @Field private String description; @NotNull @Field private String companyId; @Field private List<String> users = new ArrayList<>(); @Field private boolean removed = false; } |
|
1 2 3 4 5 6 7 8 9 10 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "securityGroup") public interface SecurityGroupRepository extends CouchbasePagingAndSortingRepository<SecurityGroup, String> { @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and companyId = $1 and removed = false " + " AND ARRAY_CONTAINS(users, $2) ") List<SecurityGroup> listUserGroups(String companyId, String userId); } |
O BasicEntity A classe também é um pequeno hack para trabalhar melhor com o Spring Data e o Couchbase:
|
1 2 3 4 5 6 7 |
public class BasicEntity { @Getter(PROTECTED) @Setter(PROTECTED) @Ignore protected String _class; } |
Por fim, aqui está a implementação da nossa classe 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 |
@Configuration @EnableWebMvc public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService customUserDetailsService; @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } @Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers("/oauth/token").permitAll() .antMatchers("/api-docs/**").permitAll() .anyRequest().authenticated() .and().anonymous().disable(); } @Bean public TokenStore tokenStore() { return new InMemoryTokenStore(); } @Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } @Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } } |
Não podemos mais injetar diretamente o bean AuthenticationManager no Spring-Boot 2.0, mas ele ainda é necessário para a estrutura Spring Security. Portanto, precisamos implementar um pequeno hack para obter acesso a esse objeto:
|
1 2 3 4 5 |
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } |
Vamos dividir essa classe em pequenas partes para entender o que está acontecendo:
|
1 2 3 4 |
@Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } |
A senha do meu usuário está em texto simples, então eu simplesmente retorno uma nova instância de NoOpPasswordEncoder. Um padrão comum é retornar uma instância da classe BCryptPasswordEncoder.
|
1 2 3 4 |
@Bean public TokenStore tokenStore() { return new InMemoryTokenStore(); } |
Por enquanto, usaremos um armazenamento de tokens na memória. Veremos na parte 2 como usar também o Couchbase como um armazenamento de tokens.
|
1 2 3 4 5 |
@Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } |
É aqui que a mágica acontece com o OAuth2 e o Spring Boot. Especificamente, estamos dizendo ao Spring para usar nosso CustomUserDetailsService para pesquisar usuários. Esse bloco de código é a parte principal do que fizemos até agora.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } |
Esse bloco nos permitirá fazer solicitações usando CORS (Cross-Origin Resource Sharing)
|
1 2 3 4 |
@Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } |
E, por fim, se você precisar chamar sua API por meio do JQuery, também precisará adicionar o código acima. Caso contrário, você receberá uma mensagem "A resposta para o preflight não tem o status HTTP ok." Erro.
Agora só falta uma coisa: precisamos adicionar um servidor de autorização:
|
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 |
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.TokenStore; @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { static final String CLIENT_ID = "android-client"; static final String CLIENT_SECRET = "android-secret"; static final String GRANT_TYPE_PASSWORD = "password"; static final String AUTHORIZATION_CODE = "authorization_code"; static final String REFRESH_TOKEN = "refresh_token"; static final String IMPLICIT = "implicit"; static final String SCOPE_READ = "read"; static final String SCOPE_WRITE = "write"; static final String TRUST = "trust"; static final int ACCESS_TOKEN_VALIDITY_SECONDS = 1*60*60; static final int REFRESH_TOKEN_VALIDITY_SECONDS = 6*60*60; @Autowired private TokenStore tokenStore; @Autowired private AuthenticationManager authenticationManager; @Override public void configure(ClientDetailsServiceConfigurer configurer) throws Exception { configurer .inMemory() .withClient(CLIENT_ID) .secret(CLIENT_SECRET) .authorizedGrantTypes(GRANT_TYPE_PASSWORD, AUTHORIZATION_CODE, REFRESH_TOKEN, IMPLICIT ) .scopes(SCOPE_READ, SCOPE_WRITE, TRUST) .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY_SECONDS). refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY_SECONDS); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(tokenStore) .authenticationManager(authenticationManager); } @Override public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } } |
Muito bem, agora você pode iniciar seu aplicativo e chamá-lo por meio do Postman ou do 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 data = { "grant_type": "password", "username": "myuser", "password":"mypassword", "client_id":"android-client", "client_secret":"android-secret" } $.ajax({ 'url': "https://localhost:8080/oauth/token", 'type': 'POST', "crossDomain": true, "headers": { 'Authorization': 'Basic YW5kcm9pZC1jbGllbnQ6YW5kcm9pZC1zZWNyZXQ=', //android-client:android-secret in Base64 'Content-Type':'application/x-www-form-urlencoded'}, "data":data, 'success': function (result) { console.log( "My Access token = "+ result.access_token); console.log( "My refresh token = "+ result.refresh_token); console.log("expires in = "+result.expires_in) succesCallback() }, 'error': function (XMLHttpRequest, textStatus, errorThrown) { errorCallback(XMLHttpRequest, textStatus, errorThrown) } }); |
Aumento do desempenho
Se você estiver usando o Couchbase, sugiro que use o nome de usuário como a chave do seu documento. Isso permitirá que você use o Armazenamento de chave-valor em vez de executar consultas N1QL, o que aumentará significativamente o desempenho de seu login.
Se você tiver alguma dúvida sobre o Couchbase, a segurança do OAuth ou a otimização da autenticação do Spring e do OAuth2, envie um tweet para @deniswsrosa
Artigo muito bom. Você poderia compartilhar o código desta postagem?