iGRPIGRP 3.0Documentation

Segurança e configuração

Variáveis de ambiente do serviço Spring Boot e configuração de segurança — SecurityConfig e IAMUserProfileSyncFilter.

5. application.properties / variáveis de ambiente

Bloco mínimo obrigatório:

# ─── Identidade deste serviço ───────────────────────────────────────────────
# Usado como nome do Resource no Access Management, como audience do JWT que a
# verificação do resource server espera, e nos logs de auditoria.
spring.application.name=my-invoice-service

# ─── Onde vive o Access Management ──────────────────────────────────────────
# Issuer URI do AS — o resource server usa isto para descoberta de JWKS e
# validação de `iss`. Tem de ser o URL público EXATO (igual à claim iss byte a
# byte) — http vs https, barra final, host, porta, prefixo de caminho.
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://api-demoigrp.nosi.cv/igrp-access-management

# URL base que a biblioteca de cliente M2M usa para /api/m2m/* e /oauth2/token.
# OBRIGATÓRIO — não tem default. Se faltar, o bean ApiClient falha a inicializar
# e o contexto não arranca.
igrp.access.api.base-url=https://api-demoigrp.nosi.cv/igrp-access-management

# ─── Credenciais do cliente M2M (usadas pelo AuthorizationSyncRunner) ───────
# Este cliente está registado no Access Management com
# grant_type=client_credentials e o(s) scope(s) que precisa para chamar
# /api/m2m/*. Nunca faças commit do secret — injeta por variável de ambiente.
igrp.access.m2m.client-id=my-invoice-service-m2m
igrp.access.m2m.client-secret=${IGRP_ACCESS_M2M_CLIENT_SECRET:}
# Opcional — só se o registo do cliente restringir os scopes disponíveis:
igrp.access.m2m.scope=m2m

Mapeamento de variáveis de ambiente

As propriedades acima são lidas pela via padrão do Spring (@Value → ${...}). Sobrepõe-as em tempo de deploy com variáveis de ambiente em maiúsculas separadas por _:

PropriedadeVariável de ambiente
igrp.access.m2m.client-idIGRP_ACCESS_M2M_CLIENT_ID
igrp.access.m2m.client-secretIGRP_ACCESS_M2M_CLIENT_SECRET
igrp.access.m2m.scopeIGRP_ACCESS_M2M_SCOPE
igrp.access.m2m.sync-tokenIGRP_ACCESS_M2M_SYNC_TOKEN
igrp.access.api.base-urlIGRP_ACCESS_API_BASE_URL
spring.security.oauth2.resourceserver.jwt.issuer-uriSPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
spring.application.nameSPRING_APPLICATION_NAME

Legado: igrp.access.m2m.sync-token

O AuthorizationSyncRunner ainda lê um valor estático igrp.access.m2m.sync-token, já deprecated, como fallback quando não há par de client-credentials configurado.

Não usar em deployments novos

Só existe para projetos anteriores à 0.2.0-beta que ainda não migraram. O caminho do token estático não é protegido por rotação de tokens e será removido numa versão futura da framework.

# Apenas para projetos legados anteriores à 0.2.0-beta:
igrp.access.m2m.sync-token=${IGRP_ACCESS_M2M_SYNC_TOKEN:}

Parâmetros opcionais da framework

# Desliga o @EnableMethodSecurity automático da framework (default: true).
# Só desliga isto se registares o teu próprio MethodSecurityConfig.
igrp.framework.auth.method-security.enabled=false

# Troca o adaptador de claims do JWT. `igrp` é o default e lê as claims
# emitidas pelo Access Management (permissions / selectedRole / org /
# is_super_admin). Só muda isto se fornecerdes um bean
# IAuthorizationServiceAdapter personalizado.
igrp.authorization.service.adapter=igrp

6. Classe de configuração de segurança

Normalmente são precisas duas peças:

  1. A configuração Spring Security que liga a validação de JWT do resource server e ativa a segurança a nível de método (@PreAuthorize).
  2. Um IAMUserProfileSyncFilter que espelha as claims de identidade do JWT numa tabela local t_iam_user_profile, para que o modelo de negócio possa fazer join contra FKs de utilizador sem ir ao Access Management a cada pedido.

6.1 SecurityConfig

package cv.igrp.demo.shared.security;

import cv.igrp.framework.auth.core.security.IgrpJwtAuthenticationConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableMethodSecurity   // ← ativa @PreAuthorize / @PostAuthorize
public class SecurityConfig {

    private final IgrpJwtAuthenticationConverter jwtConverter;
    private final IAMUserProfileSyncFilter iamUserProfileSyncFilter;

    public SecurityConfig(IgrpJwtAuthenticationConverter jwtConverter,
                          IAMUserProfileSyncFilter iamUserProfileSyncFilter) {
        this.jwtConverter = jwtConverter;
        this.iamUserProfileSyncFilter = iamUserProfileSyncFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html",
                    "/actuator/health/**", "/actuator/info"
                ).permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
                jwt.jwtAuthenticationConverter(jwtConverter)
            ))
            // Depois de o JWT ser validado e a Authentication estar no contexto,
            // espelha as claims de identidade na nossa tabela local
            // t_iam_user_profile. Fazê-lo aqui (em vez de por pedido nos
            // controladores) significa que qualquer handler autenticado pode
            // contar com a existência de uma linha local.
            .addFilterAfter(iamUserProfileSyncFilter,
                            org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration c = new CorsConfiguration();
        c.setAllowedOrigins(List.of("https://my-spa.example.com"));   // nunca "*" com credenciais
        c.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        c.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        c.setAllowCredentials(true);
        var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", c);
        return source;
    }
}

O que cada peça faz, em uma linha:

  • @EnableMethodSecurity — ativa a avaliação de @PreAuthorize / @PostAuthorize. Desde o core-spring-boot 0.2.0-beta, o MethodSecurityEnableConfig da framework ativa a segurança de método por default (@ConditionalOnProperty(matchIfMissing=true) sobre igrp.framework.auth.method-security.enabled), e o MethodSecurityConfig registra automaticamente IgrpMethodSecurityExpressionHandler + IgrpTypeLocator, para que o SpEL resolva T(Permission) sem o caminho completo do package. Manter @EnableMethodSecurity na tua SecurityConfig é inofensivo e continua a ser legível, mas já não é obrigatório.

  • IgrpJwtAuthenticationConverter (autoconfigurado por core-spring-boot) — delega num IAuthorizationServiceAdapter (default: IgrpAuthorizationServiceAdapter) que lê quatro claims do JWT emitido pelo Access Management e converte cada uma em GrantedAuthority:

    • permissions (Set<String>) — cada entrada torna-se um SimpleGrantedAuthority("<code>").
    • selectedRole (String — o perfil atualmente ativo desta sessão) — torna-se ROLE_<code> e GROUP_<code>.
    • org (String — o código do departamento do perfil ativo) — torna-se <code> e GROUP_<code>.
    • is_super_admin (boolean) — quando true, adiciona a authority marcadora IGRP_SUPER_ADMIN (IgrpAuthorizationService.SUPER_ADMIN_AUTHORITY).

    O conversor adiciona também sempre ROLE_ACTIVITI_USER.

  • oauth2ResourceServer.jwt(...) — ligação padrão do Spring. Usa spring.security.oauth2.resourceserver.jwt.issuer-uri para buscar o JWKS e validar assinatura, exp e iss.

  • STATELESS — APIs não precisam de HttpSession; cada pedido autentica-se por Bearer.

Cache do adaptador

Os métodos de IgrpAuthorizationServiceAdapter são @Cacheable (igrp.auth.rolesCache, permissionsCache, departmentsCache, superAdminCache), com chave na string do JWT. Se a aplicação consumidora não configurar um cache manager do Spring (@EnableCaching + um provider em spring.cache.type), as anotações degradam para no-op — o código continua correto, apenas sem cache.

A semântica dos perfis mudou

Só o perfil atualmente selecionado é exposto como ROLE_<code>, não a união de todos os perfis atribuídos ao utilizador. hasRole('X') avalia portanto contra um perfil de cada vez — o que o utilizador escolheu no login. Desenha as verificações contra permissões, não perfis, sempre que possível.

6.2 IAMUserProfileSyncFilter

Este filtro corre uma vez por pedido autenticado e espelha as claims de identidade do JWT numa linha local t_iam_user_profile. A chave primária da linha é o sub do JWT (o id de utilizador emitido pelo Access Management). É assim que o teu modelo de domínio (Invoice.createdBy, Approval.approvedBy, etc.) pode ter uma verdadeira foreign key em vez de uma string opaca.

Duas ressalvas antes de copiares este filtro

  1. sub não é garantidamente um UUID. Para tokens de utilizador, o Access Management põe em sub o IGRPUserEntity.id interno (atualmente uma string UUID, mas o tipo da claim é String). Para tokens de service account é o UUID de ServiceAccountEntity. Se o teu schema exigir que a coluna de id seja UUID, envolve o UUID.fromString(sub) em try/catch e cai para um UUID determinístico derivado de sub — ou muda o tipo da coluna para String.
  2. As claims de identidade dependem dos scopes. preferred_username, email, given_name, family_name, picture e locale só existem quando o token foi emitido com os scopes openid profile email. O registo do teu cliente OAuth2 no Access Management tem de os pedir, ou o filtro guarda nulls.
package cv.igrp.demo.shared.security;

import cv.igrp.demo.shared.infrastructure.persistence.entity.IAMUserProfileEntity;
import cv.igrp.demo.shared.infrastructure.persistence.repository.IAMUserProfileEntityRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
public class IAMUserProfileSyncFilter extends OncePerRequestFilter {

    private static final Logger LOGGER = LoggerFactory.getLogger(IAMUserProfileSyncFilter.class);

    private final IAMUserProfileEntityRepository repository;

    public IAMUserProfileSyncFilter(IAMUserProfileEntityRepository repository) {
        this.repository = repository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth instanceof JwtAuthenticationToken jwtAuth) {
            Jwt jwt = jwtAuth.getToken();
            String sub = jwt.getSubject();
            try {
                var existing = repository.findBySub(sub);
                if (existing.isEmpty()) {
                    LOGGER.debug("Creating IAM user profile. sub={}", sub);
                    repository.save(buildEntity(jwt));
                } else {
                    var updated = applyChanges(existing.get(), jwt);
                    if (updated) {
                        LOGGER.debug("Updating IAM user profile. sub={}", sub);
                        repository.save(existing.get());
                    }
                }
            } catch (Exception e) {
                // Nunca falhar o pedido porque o espelho local se portou mal.
                LOGGER.warn("Failed to sync IAM user profile. sub={}, error={}", sub, e.getMessage(), e);
            }
        }

        chain.doFilter(request, response);
    }

    private boolean applyChanges(IAMUserProfileEntity entity, Jwt jwt) {
        String preferredUsername = jwt.getClaimAsString("preferred_username");
        String email             = jwt.getClaimAsString("email");
        String firstName         = jwt.getClaimAsString("given_name");
        String lastName          = jwt.getClaimAsString("family_name");
        String fullName          = buildFullName(firstName, lastName);

        String username = (preferredUsername != null && !preferredUsername.isBlank())
            ? preferredUsername
            : email;

        boolean changed = false;
        if (username != null && !username.equals(entity.getUsername())) { entity.setUsername(username); changed = true; }
        if (!java.util.Objects.equals(email,    entity.getEmail()))     { entity.setEmail(email);       changed = true; }
        if (!java.util.Objects.equals(firstName,entity.getFirstName())) { entity.setFirstName(firstName); changed = true; }
        if (!java.util.Objects.equals(lastName, entity.getLastName()))  { entity.setLastName(lastName); changed = true; }
        if (!java.util.Objects.equals(fullName, entity.getFullName()))  { entity.setFullName(fullName); changed = true; }
        return changed;
    }

    private IAMUserProfileEntity buildEntity(Jwt jwt) {
        String sub               = jwt.getSubject();
        String preferredUsername = jwt.getClaimAsString("preferred_username");
        String email             = jwt.getClaimAsString("email");
        String firstName         = jwt.getClaimAsString("given_name");
        String lastName          = jwt.getClaimAsString("family_name");

        String username = (preferredUsername != null && !preferredUsername.isBlank())
            ? preferredUsername
            : email;
        if (username == null || username.isBlank()) {
            throw new IllegalStateException("Could not determine username from JWT claims. sub=" + sub);
        }

        var entity = new IAMUserProfileEntity();
        entity.setId(UUID.fromString(sub));
        entity.setSub(sub);
        entity.setUsername(username);
        entity.setEmail(email);
        entity.setFirstName(firstName);
        entity.setLastName(lastName);
        entity.setFullName(buildFullName(firstName, lastName));
        return entity;
    }

    private String buildFullName(String firstName, String lastName) {
        String first = StringUtils.hasText(firstName) ? firstName.strip() : null;
        String last  = StringUtils.hasText(lastName)  ? lastName.strip()  : null;
        if (first == null && last == null) return null;
        if (first == null) return last;
        if (last  == null) return first;
        return first + " " + last;
    }
}

Entidade e repositório de suporte, para que o filtro compile de forma autónoma no teu projeto:

@Audited
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
@Entity @IgrpEntity
@Table(name = "t_iam_user_profile",
    indexes = {
        @Index(name = "idx_iam_user_profile_username", columnList = "username"),
        @Index(name = "idx_iam_user_profile_full_name", columnList = "full_name"),
        @Index(name = "idx_iam_user_profile_sub",       columnList = "sub")
    }
)
public class IAMUserProfileEntity extends AuditEntity {

    @Id @Column(name = "id", unique = true, nullable = false)
    private UUID id;

    @NotBlank @Column(name = "username", unique = true, nullable = false)
    private String username;

    @Column(name = "email", unique = true)         private String email;
    @Column(name = "first_name")                   private String firstName;
    @Column(name = "last_name")                    private String lastName;
    @Column(name = "full_name")                    private String fullName;

    @NotBlank @Column(name = "sub", unique = true, nullable = false)
    private String sub;
}

@Repository
public interface IAMUserProfileEntityRepository
        extends JpaRepository<IAMUserProfileEntity, UUID>,
                JpaSpecificationExecutor<IAMUserProfileEntity>,
                RevisionRepository<IAMUserProfileEntity, UUID, Integer> {

    default IAMUserProfileEntity findByIdOrThrow(UUID id) {
        return findById(id).orElseThrow(() ->
            IgrpResponseStatusException.notFound("IAMUserProfileEntity not found for id: " + id));
    }

    Optional<IAMUserProfileEntity> findByUsername(String username);
    Optional<IAMUserProfileEntity> findBySub(String sub);
    List<IAMUserProfileEntity> findBySubIn(Collection<String> subs);
}

How is this guide?

Última atualização: