Pular para conteúdo

b. Serviço de Autenticação

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:

decoded payload of a BoxOffice token
{
  "iss": "boxoffice-auth",
  "aud": "boxoffice",
  "sub": "0b9d8a6e-4f3c-4c1e-9d52-1b0f3c2a7e11",
  "email": "ana@example.com",
  "name": "Ana",
  "scope": "user",
  "iat": 1795000000,
  "exp": 1795003600,
  "jti": "7c1f…"
}

Assinado não é criptografado

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)
services/auth/Dockerfile
# syntax=docker/dockerfile:1

# ---- build stage: compile with Maven inside a JDK image -------------------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded once
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

# ---- runtime stage: only a JRE and the jar --------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8081
# use up to 75% of the container's memory limit for the heap
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/auth/.dockerignore
target/
.idea/
.vscode/
*.iml
services/auth/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="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>BoxOffice auth: users, login, JWT issuing</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).

services/auth/src/main/resources/application.yaml
server:
  port: 8081

spring:
  application:
    name: auth
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:boxoffice}
    username: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
  flyway:
    schemas: auth
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
  mvc:
    problemdetails:
      enabled: true

jwt:
  issuer: ${JWT_ISSUER:boxoffice-auth}
  audience: boxoffice
  ttl: ${JWT_TTL:PT1H}              # ISO-8601 duration: 1 hour

admin:
  email: ${ADMIN_EMAIL:admin@boxoffice.dev}
  password: ${ADMIN_PASSWORD:boxoffice-admin}

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics
  endpoint:
    health:
      probes:
        enabled: true
services/auth/src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE auth.users (
    id             UUID         PRIMARY KEY,
    email          VARCHAR(200) NOT NULL UNIQUE,
    name           VARCHAR(200) NOT NULL,
    password_hash  VARCHAR(100) NOT NULL,          -- BCrypt, never the password itself
    role           VARCHAR(10)  NOT NULL CHECK (role IN ('USER', 'ADMIN')),
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
);

3. Usuários

services/auth/src/main/java/boxoffice/auth/User.java
package boxoffice.auth;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.UUID;

@Entity
@Table(name = "users", schema = "auth")
public class User {

    @Id
    private UUID id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String name;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Column(name = "created_at", nullable = false)
    private OffsetDateTime createdAt;

    protected User() {
        // required by JPA
    }

    public User(String email, String name, String passwordHash, Role role) {
        this.id = UUID.randomUUID();
        this.email = email;
        this.name = name;
        this.passwordHash = passwordHash;
        this.role = role;
        this.createdAt = OffsetDateTime.now();
    }

    public UUID getId() { return id; }
    public String getEmail() { return email; }
    public String getName() { return name; }
    public String getPasswordHash() { return passwordHash; }
    public Role getRole() { return role; }
}
services/auth/src/main/java/boxoffice/auth/Role.java
package boxoffice.auth;

public enum Role {
    USER, ADMIN
}
services/auth/src/main/java/boxoffice/auth/UserRepository.java
package boxoffice.auth;

import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, UUID> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}
services/auth/src/main/java/boxoffice/auth/AuthApplication.java
package boxoffice.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }
}

Os records trocados com os clientes:

services/auth/src/main/java/boxoffice/auth/RegisterIn.java
package boxoffice.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record RegisterIn(
        @NotBlank @Size(max = 200) String name,
        @NotBlank @Email @Size(max = 200) String email,
        @NotBlank @Size(min = 8, max = 72) String password) {   // BCrypt uses at most 72 bytes
}
services/auth/src/main/java/boxoffice/auth/LoginIn.java
package boxoffice.auth;

import jakarta.validation.constraints.NotBlank;

public record LoginIn(@NotBlank String email, @NotBlank String password) {
}
services/auth/src/main/java/boxoffice/auth/TokenOut.java
package boxoffice.auth;

/** OAuth-style token response. */
public record TokenOut(String accessToken, String tokenType, long expiresIn) {
}
services/auth/src/main/java/boxoffice/auth/UserOut.java
package boxoffice.auth;

import java.util.UUID;

public record UserOut(UUID id, String name, String email, Role role) {

    static UserOut of(User u) {
        return new UserOut(u.getId(), u.getName(), u.getEmail(), u.getRole());
    }
}

4. Assinando tokens e publicando a chave

services/auth/src/main/java/boxoffice/auth/TokenService.java
package boxoffice.auth;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.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.
 */
@Component
public class TokenService {

    private final RSAKey key;
    private final String issuer;
    private final String audience;
    private final Duration ttl;

    public TokenService(@Value("${jwt.issuer}") String issuer,
                        @Value("${jwt.audience}") String audience,
                        @Value("${jwt.ttl}") Duration ttl) throws JOSEException {
        // a new key pair at every start: fine for a lab, see the warning in the text
        this.key = new RSAKeyGenerator(2048)
                .keyID(UUID.randomUUID().toString())
                .keyUse(KeyUse.SIGNATURE)
                .algorithm(JWSAlgorithm.RS256)
                .generate();
        this.issuer = issuer;
        this.audience = audience;
        this.ttl = ttl;
    }

    public TokenOut issue(User user) {
        var now = Instant.now();
        var claims = new JWTClaimsSet.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();
        var header = new JWSHeader.Builder(JWSAlgorithm.RS256)
                .keyID(key.getKeyID())
                .type(JOSEObjectType.JWT)
                .build();
        try {
            var jwt = new SignedJWT(header, claims);
            jwt.sign(new RSASSASigner(key));
            return new TokenOut(jwt.serialize(), "Bearer", ttl.toSeconds());
        } catch (JOSEException e) {
            throw new IllegalStateException("could not sign the token", e);
        }
    }

    /** The public part only: {"keys":[{"kty":"RSA","kid":"...","n":"...","e":"AQAB",...}]} */
    public Map<String, Object> jwks() {
        return new JWKSet(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.

5. Cadastro, login, me

services/auth/src/main/java/boxoffice/auth/AuthService.java
package boxoffice.auth;

import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

@Service
public class AuthService {

    private static final Logger log = LoggerFactory.getLogger(AuthService.class);

    private final UserRepository users;
    private final TokenService tokens;
    // cost 10 = 2^10 rounds: deliberately slow (tens of ms) to make password guessing expensive
    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
    private final String adminEmail;
    private final String adminPassword;

    public AuthService(UserRepository users, TokenService tokens,
                       @Value("${admin.email}") String adminEmail,
                       @Value("${admin.password}") String adminPassword) {
        this.users = users;
        this.tokens = tokens;
        this.adminEmail = adminEmail;
        this.adminPassword = adminPassword;
    }

    @Transactional
    public UserOut register(RegisterIn in) {
        var email = in.email().toLowerCase();
        if (users.existsByEmail(email)) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "e-mail already registered");
        }
        var user = users.save(new User(email, in.name(), encoder.encode(in.password()), Role.USER));
        return UserOut.of(user);
    }

    @Transactional(readOnly = true)
    public TokenOut login(LoginIn in) {
        var user = 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(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials"));
        return tokens.issue(user);
    }

    @Transactional(readOnly = true)
    public UserOut find(UUID id) {
        return users.findById(id).map(UserOut::of)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "user not found"));
    }

    /** Creates the administrator on the first start (credentials from ADMIN_EMAIL / ADMIN_PASSWORD). */
    @EventListener(ApplicationReadyEvent.class)
    @Transactional
    public void createAdmin() {
        if (!users.existsByEmail(adminEmail)) {
            users.save(new User(adminEmail, "Administrator", encoder.encode(adminPassword), Role.ADMIN));
            log.info("created administrator {}", adminEmail);
        }
    }
}
services/auth/src/main/java/boxoffice/auth/AuthController.java
package boxoffice.auth;

import jakarta.validation.Valid;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService service;
    private final TokenService tokens;

    public AuthController(AuthService service, TokenService tokens) {
        this.service = service;
        this.tokens = tokens;
    }

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    public UserOut register(@Valid @RequestBody RegisterIn in) {
        return service.register(in);
    }

    @PostMapping("/login")
    public TokenOut login(@Valid @RequestBody LoginIn in) {
        return service.login(in);
    }

    /** Protected route: the gateway validated the JWT and tells us who the user is. */
    @GetMapping("/me")
    public UserOut me(@RequestHeader("X-User-Id") UUID userId) {
        return service.find(userId);
    }

    @GetMapping("/.well-known/jwks.json")
    public Map<String, Object> jwks() {
        return tokens.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.

Próximo: 2. O gateway.