O auth é dono dos usuários do BoxOffice. Ele faz três coisas: cadastra um usuário (guardando um hash BCrypt, nunca a senha), faz login (confere a senha e emite um token assinado) e publica a chave pública que permite a qualquer um verificar esses tokens. É um serviço Spring Boot comum na porta 8081, com o seu próprio schema auth.
flowchart LR
subgraph auth["auth :8081"]
ctl[AuthController] --> svc[AuthService]
svc --> repo[UserRepository] --> db[("schema auth<br/>users")]
svc --> tok["TokenService<br/>par de chaves RSA"]
end
gw[gateway] -->|"GET /auth/.well-known/jwks.json<br/>(chave pública, em cache)"| ctl
1. Por que um token assinado?
Depois do login, toda requisição precisa provar quem está chamando. Duas famílias de soluções:
Sessão no servidor
Token autocontido (JWT)
O que o cliente guarda
um id de sessão aleatório (cookie)
as próprias claims, assinadas: sub, scope, exp…
Como uma requisição é verificada
busca o id em um armazenamento de sessões (Redis, banco)
verifica a assinatura com a chave pública — sem busca
Revogação
apaga a sessão: imediata
difícil: o token vale até o exp (mantenha-o curto)
Escala com
o armazenamento de sessões
só CPU; cada nó verifica sozinho
O BoxOffice usa JWTs assinados com RS256 (RSA + SHA-256): o auth assina com uma chave privada que só ele conhece; o gateway verifica com a chave pública, publicada como um JWKS (JSON Web Key Set). O gateway consegue verificar tokens mas nunca consegue emitir um — ao contrário do HS256, em que o mesmo segredo compartilhado assina e verifica, e todo serviço que o tivesse poderia forjar tokens.
Um token tem a forma header.payload.signature, cada parte codificada em Base64URL:
Qualquer um consegue decodificar o payload (experimente o jwt.io, ou o comando jq no fim da sessão de curl da página do lab). Nunca coloque em um JWT segredos ou dados pessoais que você não mostraria ao próprio usuário.
2. Projeto e configuração
Crie services/auth como o catalog (mesmo Dockerfile com EXPOSE 8081, mesmo .dockerignore).
services/auth/Dockerfile e .dockerignore (iguais aos do catalog, só muda o EXPOSE 8081)
# syntax=docker/dockerfile:1# ---- build stage: compile with Maven inside a JDK image -------------------FROMmaven:3.9-eclipse-temurin-21ASbuildWORKDIR/srcCOPYpom.xml.
COPYsrc./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded onceRUN--mount=type=cache,target=/root/.m2mvn-B-qpackage-DskipTests
# ---- runtime stage: only a JRE and the jar --------------------------------FROMeclipse-temurin:21-jreWORKDIR/appRUNuseradd--system--uid10001app
COPY--from=build/src/target/app.jarapp.jar
USERappEXPOSE8081# use up to 75% of the container's memory limit for the heapENTRYPOINT["java","-XX:MaxRAMPercentage=75","-jar","app.jar"]
<?xml version="1.0" encoding="UTF-8"?><projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent><groupId>boxoffice</groupId><artifactId>auth</artifactId><version>1.0.0</version><name>auth</name><description>BoxOfficeauth:users,login,JWTissuing</description><properties><java.version>21</java.version><!-- the same version Spring Security 7.1 uses in the gateway --><nimbus-jose-jwt.version>10.9.1</nimbus-jose-jwt.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency><!-- schema migrations (Boot 4: the starter brings the auto-configuration) --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency><dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency><dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency><!-- BCrypt password hashing only (not the whole Spring Security filter chain) --><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-crypto</artifactId></dependency><!-- JWT signing (RS256) and the JWKS document --><dependency><groupId>com.nimbusds</groupId><artifactId>nimbus-jose-jwt</artifactId><version>${nimbus-jose-jwt.version}</version></dependency></dependencies><build><finalName>app</finalName><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
Repare no que não está lá: o starter completo do Spring Security. O auth só precisa do encoder BCrypt (spring-security-crypto) e de uma biblioteca de JWT (nimbus-jose-jwt, a mesma que o Spring Security usa dentro do gateway).
CREATETABLEauth.users(idUUIDPRIMARYKEY,emailVARCHAR(200)NOTNULLUNIQUE,nameVARCHAR(200)NOTNULL,password_hashVARCHAR(100)NOTNULL,-- BCrypt, never the password itselfroleVARCHAR(10)NOTNULLCHECK(roleIN('USER','ADMIN')),created_atTIMESTAMPTZNOTNULLDEFAULTnow());
packageboxoffice.auth;importjakarta.persistence.Column;importjakarta.persistence.Entity;importjakarta.persistence.EnumType;importjakarta.persistence.Enumerated;importjakarta.persistence.Id;importjakarta.persistence.Table;importjava.time.OffsetDateTime;importjava.util.UUID;@Entity@Table(name="users",schema="auth")publicclassUser{@IdprivateUUIDid;@Column(nullable=false,unique=true)privateStringemail;@Column(nullable=false)privateStringname;@Column(name="password_hash",nullable=false)privateStringpasswordHash;@Enumerated(EnumType.STRING)@Column(nullable=false)privateRolerole;@Column(name="created_at",nullable=false)privateOffsetDateTimecreatedAt;protectedUser(){// required by JPA}publicUser(Stringemail,Stringname,StringpasswordHash,Rolerole){this.id=UUID.randomUUID();this.email=email;this.name=name;this.passwordHash=passwordHash;this.role=role;this.createdAt=OffsetDateTime.now();}publicUUIDgetId(){returnid;}publicStringgetEmail(){returnemail;}publicStringgetName(){returnname;}publicStringgetPasswordHash(){returnpasswordHash;}publicRolegetRole(){returnrole;}}
packageboxoffice.auth;importjakarta.validation.constraints.Email;importjakarta.validation.constraints.NotBlank;importjakarta.validation.constraints.Size;publicrecordRegisterIn(@NotBlank@Size(max=200)Stringname,@NotBlank@Email@Size(max=200)Stringemail,@NotBlank@Size(min=8,max=72)Stringpassword){// BCrypt uses at most 72 bytes}
packageboxoffice.auth;importcom.nimbusds.jose.JOSEException;importcom.nimbusds.jose.JOSEObjectType;importcom.nimbusds.jose.JWSAlgorithm;importcom.nimbusds.jose.JWSHeader;importcom.nimbusds.jose.crypto.RSASSASigner;importcom.nimbusds.jose.jwk.JWKSet;importcom.nimbusds.jose.jwk.KeyUse;importcom.nimbusds.jose.jwk.RSAKey;importcom.nimbusds.jose.jwk.gen.RSAKeyGenerator;importcom.nimbusds.jwt.JWTClaimsSet;importcom.nimbusds.jwt.SignedJWT;importjava.time.Duration;importjava.time.Instant;importjava.util.Date;importjava.util.Map;importjava.util.UUID;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.stereotype.Component;/** * Signs JWTs with a private RSA key (RS256) and publishes the public key as a JWKS. * Only auth holds the private key; anyone can verify a token with the public one. */@ComponentpublicclassTokenService{privatefinalRSAKeykey;privatefinalStringissuer;privatefinalStringaudience;privatefinalDurationttl;publicTokenService(@Value("${jwt.issuer}")Stringissuer,@Value("${jwt.audience}")Stringaudience,@Value("${jwt.ttl}")Durationttl)throwsJOSEException{// a new key pair at every start: fine for a lab, see the warning in the textthis.key=newRSAKeyGenerator(2048).keyID(UUID.randomUUID().toString()).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256).generate();this.issuer=issuer;this.audience=audience;this.ttl=ttl;}publicTokenOutissue(Useruser){varnow=Instant.now();varclaims=newJWTClaimsSet.Builder().issuer(issuer).audience(audience).subject(user.getId().toString()).claim("email",user.getEmail()).claim("name",user.getName()).claim("scope",user.getRole()==Role.ADMIN?"user admin":"user").issueTime(Date.from(now)).expirationTime(Date.from(now.plus(ttl))).jwtID(UUID.randomUUID().toString()).build();varheader=newJWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).type(JOSEObjectType.JWT).build();try{varjwt=newSignedJWT(header,claims);jwt.sign(newRSASSASigner(key));returnnewTokenOut(jwt.serialize(),"Bearer",ttl.toSeconds());}catch(JOSEExceptione){thrownewIllegalStateException("could not sign the token",e);}}/** The public part only: {"keys":[{"kty":"RSA","kid":"...","n":"...","e":"AQAB",...}]} */publicMap<String,Object>jwks(){returnnewJWKSet(key.toPublicJWK()).toJSONObject();}}
Claim
Significado
Verificada pelo gateway
iss
quem emitiu (boxoffice-auth)
sim: precisa bater com JWT_ISSUER
aud
para quem é (boxoffice)
sim: um token de outra API é recusado
sub
o id do usuário
vira X-User-Id
scope
user ou user admin
vira as authorities SCOPE_user, SCOPE_admin
exp
expiração
sim (com 60 s de tolerância de diferença de relógio)
kid (header)
qual chave assinou
usado para escolher a chave certa no JWKS
Uma chave nova a cada reinício
O TokenService gera o par de chaves RSA em memória quando o auth sobe. Reinicie o auth e todo token emitido antes é rejeitado (kid desconhecido), e duas réplicas do auth assinariam cada uma com uma chave diferente. Em produção, a chave privada fica em um KMS ou gerenciador de segredos, o JWKS lista a chave atual e a anterior durante uma rotação, e as chaves giram em um calendário. Num lab, um reinício que desloga todo mundo é uma vantagem: você vê por que a rotação exige cuidado.
packageboxoffice.auth;importjava.util.UUID;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.boot.context.event.ApplicationReadyEvent;importorg.springframework.context.event.EventListener;importorg.springframework.http.HttpStatus;importorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importorg.springframework.web.server.ResponseStatusException;@ServicepublicclassAuthService{privatestaticfinalLoggerlog=LoggerFactory.getLogger(AuthService.class);privatefinalUserRepositoryusers;privatefinalTokenServicetokens;// cost 10 = 2^10 rounds: deliberately slow (tens of ms) to make password guessing expensiveprivatefinalBCryptPasswordEncoderencoder=newBCryptPasswordEncoder(10);privatefinalStringadminEmail;privatefinalStringadminPassword;publicAuthService(UserRepositoryusers,TokenServicetokens,@Value("${admin.email}")StringadminEmail,@Value("${admin.password}")StringadminPassword){this.users=users;this.tokens=tokens;this.adminEmail=adminEmail;this.adminPassword=adminPassword;}@TransactionalpublicUserOutregister(RegisterInin){varemail=in.email().toLowerCase();if(users.existsByEmail(email)){thrownewResponseStatusException(HttpStatus.CONFLICT,"e-mail already registered");}varuser=users.save(newUser(email,in.name(),encoder.encode(in.password()),Role.USER));returnUserOut.of(user);}@Transactional(readOnly=true)publicTokenOutlogin(LoginInin){varuser=users.findByEmail(in.email().toLowerCase()).filter(u->encoder.matches(in.password(),u.getPasswordHash()))// same answer for "no such user" and "wrong password": do not help attackers.orElseThrow(()->newResponseStatusException(HttpStatus.UNAUTHORIZED,"invalid credentials"));returntokens.issue(user);}@Transactional(readOnly=true)publicUserOutfind(UUIDid){returnusers.findById(id).map(UserOut::of).orElseThrow(()->newResponseStatusException(HttpStatus.NOT_FOUND,"user not found"));}/** Creates the administrator on the first start (credentials from ADMIN_EMAIL / ADMIN_PASSWORD). */@EventListener(ApplicationReadyEvent.class)@TransactionalpublicvoidcreateAdmin(){if(!users.existsByEmail(adminEmail)){users.save(newUser(adminEmail,"Administrator",encoder.encode(adminPassword),Role.ADMIN));log.info("created administrator {}",adminEmail);}}}
packageboxoffice.auth;importjakarta.validation.Valid;importjava.util.Map;importjava.util.UUID;importorg.springframework.http.HttpStatus;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.PostMapping;importorg.springframework.web.bind.annotation.RequestBody;importorg.springframework.web.bind.annotation.RequestHeader;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.ResponseStatus;importorg.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/auth")publicclassAuthController{privatefinalAuthServiceservice;privatefinalTokenServicetokens;publicAuthController(AuthServiceservice,TokenServicetokens){this.service=service;this.tokens=tokens;}@PostMapping("/register")@ResponseStatus(HttpStatus.CREATED)publicUserOutregister(@Valid@RequestBodyRegisterInin){returnservice.register(in);}@PostMapping("/login")publicTokenOutlogin(@Valid@RequestBodyLoginInin){returnservice.login(in);}/** Protected route: the gateway validated the JWT and tells us who the user is. */@GetMapping("/me")publicUserOutme(@RequestHeader("X-User-Id")UUIDuserId){returnservice.find(userId);}@GetMapping("/.well-known/jwks.json")publicMap<String,Object>jwks(){returntokens.jwks();}}
Detalhes que importam:
BCrypt com custo 10 significa 2¹⁰ rodadas de expansão de chave: dezenas de milissegundos por hash. Rápido o bastante para um humano fazendo login, lento o bastante para tornar caro um ataque offline a uma tabela vazada. Isso também faz do /auth/login o endpoint que mais consome CPU no BoxOffice — você vai medir.
Mesmo erro para "e-mail desconhecido" e "senha errada" (401 invalid credentials): mensagens diferentes deixariam um atacante descobrir quais e-mails têm conta.
O /auth/me não lê o token: ele confia no X-User-Id, definido pelo gateway depois de validá-lo. O auth não é alcançável de fora a não ser pelo gateway.
O administrador é criado uma vez, no ApplicationReadyEvent, a partir de ADMIN_EMAIL/ADMIN_PASSWORD. Troque a senha em qualquer ambiente compartilhado.
Na entrevista
"Autenticação no gateway, autorização perto dos dados": o gateway rejeita cedo e barato requisições anônimas ou malformadas; as regras finas ("esta reserva é sua?") ficam no serviço dono dos dados — o inventory confere o user_id em toda reserva no lab 6.