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

@@ -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());
}
}