auth owns the users of BoxOffice. It does three things: register a user (storing a BCrypt hash, never the password), log in (check the password and issue a signed token) and publish the public key that lets anybody verify those tokens. It is a regular Spring Boot service on port 8081, with its own schema auth.
After login, every request must prove who is calling. Two families of solutions:
Server-side session
Self-contained token (JWT)
What the client holds
a random session id (cookie)
the claims themselves, signed: sub, scope, exp…
How a request is checked
look the id up in a session store (Redis, DB)
verify the signature with the public key — no lookup
Revocation
delete the session: immediate
hard: the token is valid until exp (keep it short)
Scales with
the session store
CPU only; every node can verify alone
BoxOffice uses JWTs signed with RS256 (RSA + SHA-256): auth signs with a private key only it knows; the gateway verifies with the public key, published as a JWKS (JSON Web Key Set). The gateway can verify tokens but can never mint one — unlike HS256, where the same shared secret signs and verifies, and every service holding it could forge tokens.
A token looks like header.payload.signature, each part Base64URL-encoded:
Anybody can decode the payload (try jwt.io, or the jq command at the end of the curl session on the lab page). Never put secrets or personal data you would not show the user in a JWT.
2. Project and configuration
Create services/auth like catalog (same Dockerfile with EXPOSE 8081, same .dockerignore).
services/auth/Dockerfile and .dockerignore (same as catalog, only EXPOSE 8081 changes)
# 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>
Note what is not there: the full Spring Security starter. auth only needs the BCrypt encoder (spring-security-crypto) and a JWT library (nimbus-jose-jwt, the same one Spring Security uses inside the 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
Meaning
Checked by the gateway
iss
who issued it (boxoffice-auth)
yes: must match JWT_ISSUER
aud
who it is for (boxoffice)
yes: a token for another API is refused
sub
the user id
becomes X-User-Id
scope
user or user admin
becomes the authorities SCOPE_user, SCOPE_admin
exp
expiry
yes (with a 60 s clock-skew allowance)
kid (header)
which key signed it
used to pick the right key from the JWKS
A new key at every restart
TokenService generates the RSA key pair in memory when auth starts. Restart auth and every token issued before is rejected (unknown kid), and two auth replicas would each sign with a different key. In production the private key lives in a KMS or secrets manager, the JWKS lists the current and the previous key during a rotation, and keys rotate on a schedule. For a lab, a restart that logs everybody out is a feature: you see why rotation needs care.
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();}}
Details that matter:
BCrypt cost 10 means 2¹⁰ rounds of key expansion: tens of milliseconds per hash. Fast enough for a human logging in, slow enough to make an offline attack on a leaked table expensive. It also makes /auth/login the most CPU-hungry endpoint of BoxOffice — you will measure it.
Same error for "unknown e-mail" and "wrong password" (401 invalid credentials): different messages would let an attacker discover which e-mails have accounts.
/auth/me does not read the token: it trusts X-User-Id, set by the gateway after validating it. auth is not reachable from outside except through the gateway.
The administrator is created once, on ApplicationReadyEvent, from ADMIN_EMAIL/ADMIN_PASSWORD. Change the password in any shared environment.
In the interview
"Authentication at the gateway, authorization close to the data": the gateway rejects anonymous or malformed requests early and cheaply; fine-grained rules ("is this hold yours?") stay in the service that owns the data — inventory checks user_id on every hold in lab 6.