feat(profile): opt-in public profile with a ticket for Core

user_settings.public_profile (off by default, needs a display name).
GET /api/public/profiles/{displayName} answers only for enabled users with a
public profile and returns a 10-minute EdDSA ticket for Core. A token with a
typ claim is no login, so the ticket cannot be used as one.
This commit is contained in:
lobo committed 2026-09-24 14:18:32 +02:00
1 parent 07ccb489ec
commit 973b980e05
21 files changed
+353 -12

No files matched your search

+10 -6
View File
@@ -106,13 +106,17 @@ Tracking known work that is not yet implemented. Each item is independently acti
The full cross-service product roadmap lives in `ToBePlayed-Deployment/README.md`. This section lists only the slices that land in Auth.
### 1. Username + public-profile opt-in
### 1. ~~Username + public-profile opt-in~~ Done
The public profile feature (`tbp.andreas-dahm.eu/u/<username>`) requires:
- **Unique, human-readable username** distinct from email. Schema: add `username` column on `users` with a unique index and a Flyway migration. Validation: 3–30 chars, `[a-z0-9_-]`, case-insensitive uniqueness.
- **`profile_public` flag** (boolean, default `false`) on `users`. Exposed via `PUT /api/users/settings`.
- New endpoint: `GET /api/public/users/{username}` returning `{ username, displayName, steamId?, joinedAt }` — the public-facing identity contract that Core's profile endpoint can compose with.
- Display name (3–24 chars, unique case-insensitive) is the public handle; `/u/<display name>` in the frontend.
- `user_settings.public_profile` (default `false`), set via `PUT /api/users/settings` (`publicProfile`). A public
profile needs a display name (400 otherwise).
- `GET /api/public/profiles/{displayName}` → `{displayName, memberSince, ticket}` for enabled users with a public
profile; everything else is the same 404 `PROFILE_NOT_FOUND`.
- The `ticket` is an EdDSA JWT (`typ=public-profile`, `profileUserId`, **valid 10 minutes**) for Core's
`GET /api/public/profiles/library` (header `X-Profile-Ticket`). It is no login: `JwtService.isTokenValid` rejects
every token with a `typ` claim, in Auth and in Core. Accepted trade-off: after a user makes the profile private, a
ticket issued before still reads the public data for up to 10 minutes; Core stores no copy of the flag.
### 2. Friends / social graph
@@ -1,5 +1,6 @@
package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.TokenGenerator;
import io.jsonwebtoken.Jwts;
import jakarta.annotation.PostConstruct;
@@ -18,10 +19,14 @@ import java.util.Date;
* Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
*/
@Service
public class JwtService implements TokenGenerator {
public class JwtService implements TokenGenerator, ProfileTicketIssuer {
// Token validity: 24 hours in milliseconds
private static final long JWT_EXPIRATION_MS = 86400000;
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
// Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String ED25519 = "Ed25519";
private final JwtConfig jwtConfig;
@@ -56,6 +61,19 @@ public class JwtService implements TokenGenerator {
.compact();
}
@Override
public String generateProfileTicket(long userId) {
return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.subject(PROFILE_TICKET_TYPE)
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
.claim("profileUserId", String.valueOf(userId))
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
.signWith(privateKey, Jwts.SIG.EdDSA)
.compact();
}
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith(publicKey)
@@ -83,13 +101,17 @@ public class JwtService implements TokenGenerator {
.get("steamId", Long.class);
}
/**
* True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login.
*/
public boolean isTokenValid(String token) {
try {
Jwts.parser()
var claims = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token);
return true;
.parseSignedClaims(token)
.getPayload();
return claims.get(TYPE_CLAIM) == null;
} catch (Exception _) {
return false;
}
@@ -36,6 +36,7 @@ public class SecurityConfig {
"/api/auth/**", // Allow login, registration, etc.
"/api/registration/**", // Allow new user registration
"/api/stats/public", // Public landing-page counters (cached, no PII)
"/api/public/**", // Opt-in public profiles, see PublicProfileController
"/actuator/health", // Docker health check; details are off
"/error" // Default Spring Boot error page routing
};
@@ -0,0 +1,33 @@
package auth_service.modules.profile.api;
import auth_service.modules.profile.api.dto.PublicProfileResponse;
import auth_service.modules.profile.service.PublicProfileService;
import auth_service.shared.api.dto.response.ErrorResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/public/profiles")
@RequiredArgsConstructor
public class PublicProfileController {
private final PublicProfileService publicProfileService;
/**
* Unknown, disabled and private profiles get the same 404, so the answer does not tell them apart.
*/
@GetMapping("/{displayName}")
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
return publicProfileService.findPublicProfile(displayName)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse(
p.profile().displayName(), p.profile().memberSince(), p.ticket())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
}
}
@@ -0,0 +1,8 @@
package auth_service.modules.profile.api.dto;
import java.time.LocalDate;
/**
* @param ticket for Core's public profile endpoint; valid for a few minutes
*/
public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket) {}
@@ -0,0 +1,5 @@
package auth_service.modules.profile.model;
import java.time.LocalDate;
public record PublicProfile(long userId, String displayName, LocalDate memberSince) {}
@@ -0,0 +1,32 @@
package auth_service.modules.profile.repo;
import auth_service.modules.profile.model.PublicProfile;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class PublicProfileRepository {
private final JdbcTemplate jdbc;
public PublicProfileRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/**
* Only enabled users who switched the public profile on. The name ignores case, like its unique index.
*/
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND s.public_profile
""",
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null),
displayName).stream().findFirst();
}
}
@@ -0,0 +1,27 @@
package auth_service.modules.profile.service;
import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.repo.PublicProfileRepository;
import auth_service.shared.security.ProfileTicketIssuer;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {}
private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer;
/**
* @return empty for an unknown, disabled or private profile
*/
public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(displayName)
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId())));
}
}
@@ -3,15 +3,29 @@ package auth_service.modules.user.api.dto.request;
import auth_service.shared.api.validation.DisplayNameFormat;
import auth_service.shared.api.validation.SteamIdDeserializer;
import auth_service.shared.api.validation.SteamIdFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Pattern;
import tools.jackson.databind.annotation.JsonDeserialize;
/**
* @param steamId null removes the Steam ID
* @param displayName null removes the display name
* @param publicProfile missing counts as false, so an old client can only make a profile private
*/
public record UserSettingsRequest(boolean useDarkTheme, String language,
@Pattern(regexp = SteamIdFormat.PATTERN)
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId,
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName) {
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
Boolean publicProfile) {
/**
* A public profile without a name has no link; the user would think it is off, and the next name would be
* public at once without a new opt-in.
*/
@AssertTrue(message = "A public profile needs a display name")
@JsonIgnore
public boolean isPublicProfileNamed() {
return !Boolean.TRUE.equals(publicProfile) || displayName != null;
}
}
@@ -3,5 +3,5 @@ package auth_service.modules.user.api.dto.response;
import lombok.Builder;
@Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language) {
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile) {
}
@@ -24,6 +24,7 @@ public abstract class ApiMapper {
.id(user.getId())
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
.language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile())
.build())
.build();
}
@@ -36,6 +37,7 @@ public abstract class ApiMapper {
return UserSettings.builder()
.theme(request.useDarkTheme() ? "dark" : "light")
.language(request.language())
.publicProfile(Boolean.TRUE.equals(request.publicProfile()))
.build();
}
@@ -8,4 +8,5 @@ import lombok.Data;
public class UserSettings {
private String language;
private String theme;
private boolean publicProfile;
}
@@ -77,6 +77,7 @@ public class UserRepositoryImpl implements UserRepository {
settingsEntity.setLanguage(settings.getLanguage());
settingsEntity.setTheme(settings.getTheme());
settingsEntity.setPublicProfile(settings.isPublicProfile());
boolean steamIdChanged = !Objects.equals(userEntity.getSteamId(), steamId);
userEntity.setSteamId(steamId);
@@ -23,4 +23,7 @@ public class UserSettingsEntity {
@Column(nullable = false)
private String theme;
@Column(name = "public_profile", nullable = false)
private boolean publicProfile;
}
@@ -21,6 +21,7 @@ public abstract class EntityMapper {
settingsModel = UserSettings.builder()
.language(entity.getSettings().getLanguage())
.theme(entity.getSettings().getTheme())
.publicProfile(entity.getSettings().isPublicProfile())
.build();
} else {
// Fallback default settings for users without an entry in the database
@@ -49,6 +50,7 @@ public abstract class EntityMapper {
entity.setUserId(userId);
entity.setLanguage(settings.getLanguage());
entity.setTheme(settings.getTheme());
entity.setPublicProfile(settings.isPublicProfile());
return entity;
}
}
@@ -0,0 +1,9 @@
package auth_service.shared.security;
/**
* Port for the short-lived ticket that lets Core show the public profile data of one user. The ticket is signed
* like a login token but is no login: the login filters of Auth and Core reject it.
*/
public interface ProfileTicketIssuer {
String generateProfileTicket(long userId);
}
@@ -0,0 +1,2 @@
-- Opt-in for the public profile page /u/<display name>. Off for everyone until they switch it on.
ALTER TABLE user_settings ADD COLUMN public_profile BOOLEAN NOT NULL DEFAULT FALSE;
@@ -16,6 +16,7 @@ import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
@@ -106,4 +107,21 @@ class JwtServiceTest extends AbstractUnitTest {
void isTokenValid_Failure() {
assertFalse(jwtService.isTokenValid("invalid-token"));
}
@Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L);
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("public-profile", claims.get("typ", String.class));
assertEquals("42", claims.get("profileUserId", String.class));
assertNull(claims.get("userId"));
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s");
}
@Test
void isTokenValid_ProfileTicketIsNoLoginToken() {
assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L)));
}
}
@@ -0,0 +1,96 @@
package auth_service.modules.profile.api;
import auth_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.security.KeyFactory;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Value("${security.jwt.public-key}")
private String publicKey;
private long publicUserId;
private long insertUser(String email, String displayName, boolean enabled, boolean publicProfile) {
Long id = jdbc.queryForObject("insert into users (email, password, enabled, display_name, registration_date) "
+ "values (?, 'x', ?, ?, '2026-03-02 10:15:00') returning id", Long.class, email, enabled, displayName);
jdbc.update("insert into user_settings (user_id, language, theme, public_profile) values (?, 'de', 'dark', ?)",
id, publicProfile);
return id;
}
@BeforeEach
void seed() {
jdbc.update("delete from users where email like '%@profile.test'");
publicUserId = insertUser("public@profile.test", "Lobo", true, true);
insertUser("private@profile.test", "Shy", true, false);
insertUser("disabled@profile.test", "Gone", false, true);
}
@Test
void publicProfile_AnswersWithNameMemberSinceAndATicketForCore() throws Exception {
String body = mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.displayName").value("Lobo"))
.andExpect(jsonPath("$.memberSince").value("2026-03-02"))
.andExpect(jsonPath("$.email").doesNotExist())
.andExpect(jsonPath("$.steamId").doesNotExist())
.andReturn().getResponse().getContentAsString();
String ticket = body.replaceAll(".*\"ticket\"\\s*:\\s*\"([^\"]+)\".*", "$1");
var key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class));
}
@Test
void publicProfile_NameIgnoresCase() throws Exception {
mockMvc.perform(get("/api/public/profiles/lOBO"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.displayName").value("Lobo"));
}
@ParameterizedTest
@ValueSource(strings = {"Shy", "Gone", "Nobody"})
void publicProfile_PrivateDisabledAndUnknownLookTheSame(String name) throws Exception {
mockMvc.perform(get("/api/public/profiles/" + name))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("PROFILE_NOT_FOUND"));
}
@Test
void profileTicket_IsNoLoginForTheUserApi() throws Exception {
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
String ticket = body.replaceAll(".*\"ticket\"\\s*:\\s*\"([^\"]+)\".*", "$1");
int status = mockMvc.perform(get("/api/users/me").header("Authorization", "Bearer " + ticket))
.andReturn().getResponse().getStatus();
assertTrue(status == 401 || status == 403, "/api/users/me answered " + status);
}
}
@@ -13,6 +13,7 @@ import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
@@ -358,4 +359,46 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
@ParameterizedTest
@CsvSource({"',\"publicProfile\":true', true", "'', false", "',\"publicProfile\":null', false"})
void updateUserSettings_PassesPublicProfile_MissingIsFalse(String field, boolean expected) throws Exception {
var principal = new UserPrincipal("1", "test@example.com", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}"))
.andExpect(status().isOk());
verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"),
argThat(settings -> settings.isPublicProfile() == expected));
}
@Test
void updateUserSettings_PublicProfileWithoutDisplayNameIsRejected() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":null,\"publicProfile\":true}"))
.andExpect(status().isBadRequest());
}
@Test
void getCurrentUserV1_ShowsPublicProfile() throws Exception {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").publicProfile(true).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.publicProfile").value(true));
}
}
@@ -180,4 +180,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "LOBO"));
assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf"));
}
@Test
void updateSettings_StoresPublicProfile_AndFindByEmailReadsIt() {
var userEntity = entityManager.persist(UserEntity.builder()
.email("public@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, "Lobo",
UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
entityManager.flush();
entityManager.clear();
assertTrue(userRepository.findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
}
}