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=m2mMapeamento 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 _:
| Propriedade | Variável de ambiente |
|---|---|
igrp.access.m2m.client-id | IGRP_ACCESS_M2M_CLIENT_ID |
igrp.access.m2m.client-secret | IGRP_ACCESS_M2M_CLIENT_SECRET |
igrp.access.m2m.scope | IGRP_ACCESS_M2M_SCOPE |
igrp.access.m2m.sync-token | IGRP_ACCESS_M2M_SYNC_TOKEN |
igrp.access.api.base-url | IGRP_ACCESS_API_BASE_URL |
spring.security.oauth2.resourceserver.jwt.issuer-uri | SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI |
spring.application.name | SPRING_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=igrp6. Classe de configuração de segurança
Normalmente são precisas duas peças:
- 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). - Um
IAMUserProfileSyncFilterque espelha as claims de identidade do JWT numa tabela localt_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 ocore-spring-boot0.2.0-beta, oMethodSecurityEnableConfigda framework ativa a segurança de método por default (@ConditionalOnProperty(matchIfMissing=true)sobreigrp.framework.auth.method-security.enabled), e oMethodSecurityConfigregistra automaticamenteIgrpMethodSecurityExpressionHandler+IgrpTypeLocator, para que o SpEL resolvaT(Permission)sem o caminho completo do package. Manter@EnableMethodSecurityna tuaSecurityConfigé inofensivo e continua a ser legível, mas já não é obrigatório. -
IgrpJwtAuthenticationConverter(autoconfigurado porcore-spring-boot) — delega numIAuthorizationServiceAdapter(default:IgrpAuthorizationServiceAdapter) que lê quatro claims do JWT emitido pelo Access Management e converte cada uma emGrantedAuthority:permissions(Set<String>) — cada entrada torna-se umSimpleGrantedAuthority("<code>").selectedRole(String — o perfil atualmente ativo desta sessão) — torna-seROLE_<code>eGROUP_<code>.org(String — o código do departamento do perfil ativo) — torna-se<code>eGROUP_<code>.is_super_admin(boolean) — quandotrue, adiciona a authority marcadoraIGRP_SUPER_ADMIN(IgrpAuthorizationService.SUPER_ADMIN_AUTHORITY).
O conversor adiciona também sempre
ROLE_ACTIVITI_USER. -
oauth2ResourceServer.jwt(...)— ligação padrão do Spring. Usaspring.security.oauth2.resourceserver.jwt.issuer-uripara buscar o JWKS e validar assinatura,expeiss. -
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
subnão é garantidamente um UUID. Para tokens de utilizador, o Access Management põe emsuboIGRPUserEntity.idinterno (atualmente uma string UUID, mas o tipo da claim éString). Para tokens de service account é o UUID deServiceAccountEntity. Se o teu schema exigir que a coluna de id sejaUUID, envolve oUUID.fromString(sub)em try/catch e cai para um UUID determinístico derivado desub— ou muda o tipo da coluna paraString.- As claims de identidade dependem dos scopes.
preferred_username,email,given_name,family_name,pictureelocalesó existem quando o token foi emitido com os scopesopenid 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);
}Última atualização:
