From c9ae7853e5785000173e34925a00e61c60f920e5 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 14:18:32 +0200 Subject: [PATCH] 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. --- readme.md | 16 ++-- .../configuration/security/JwtService.java | 30 +++++- .../security/SecurityConfig.java | 1 + .../profile/api/PublicProfileController.java | 33 +++++++ .../api/dto/PublicProfileResponse.java | 8 ++ .../modules/profile/model/PublicProfile.java | 5 + .../profile/repo/PublicProfileRepository.java | 32 +++++++ .../profile/service/PublicProfileService.java | 27 ++++++ .../api/dto/request/UserSettingsRequest.java | 16 +++- .../api/dto/response/SettingsResponse.java | 2 +- .../modules/user/api/mapper/ApiMapper.java | 2 + .../modules/user/model/UserSettings.java | 1 + .../user/persistence/UserRepositoryImpl.java | 1 + .../entities/UserSettingsEntity.java | 3 + .../user/persistence/mapper/EntityMapper.java | 2 + .../shared/security/ProfileTicketIssuer.java | 9 ++ .../db/migration/V7__Add_Public_Profile.sql | 2 + .../security/JwtServiceTest.java | 18 ++++ .../api/PublicProfileControllerTest.java | 96 +++++++++++++++++++ .../modules/user/api/UserControllerTest.java | 43 +++++++++ .../user/persistence/UserRepositoryTest.java | 18 ++++ 21 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 src/main/java/auth_service/modules/profile/api/PublicProfileController.java create mode 100644 src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java create mode 100644 src/main/java/auth_service/modules/profile/model/PublicProfile.java create mode 100644 src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java create mode 100644 src/main/java/auth_service/modules/profile/service/PublicProfileService.java create mode 100644 src/main/java/auth_service/shared/security/ProfileTicketIssuer.java create mode 100644 src/main/resources/db/migration/V7__Add_Public_Profile.sql create mode 100644 src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java diff --git a/readme.md b/readme.md index e26a431..11513ea 100644 --- a/readme.md +++ b/readme.md @@ -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/`) 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/` 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 diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 18cbc0c..6f98247 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -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; } diff --git a/src/main/java/auth_service/configuration/security/SecurityConfig.java b/src/main/java/auth_service/configuration/security/SecurityConfig.java index 120c75e..56f4b62 100644 --- a/src/main/java/auth_service/configuration/security/SecurityConfig.java +++ b/src/main/java/auth_service/configuration/security/SecurityConfig.java @@ -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 }; diff --git a/src/main/java/auth_service/modules/profile/api/PublicProfileController.java b/src/main/java/auth_service/modules/profile/api/PublicProfileController.java new file mode 100644 index 0000000..035aee8 --- /dev/null +++ b/src/main/java/auth_service/modules/profile/api/PublicProfileController.java @@ -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) + .>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"))); + } +} diff --git a/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java b/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java new file mode 100644 index 0000000..49cb0fb --- /dev/null +++ b/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java @@ -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) {} diff --git a/src/main/java/auth_service/modules/profile/model/PublicProfile.java b/src/main/java/auth_service/modules/profile/model/PublicProfile.java new file mode 100644 index 0000000..b789e20 --- /dev/null +++ b/src/main/java/auth_service/modules/profile/model/PublicProfile.java @@ -0,0 +1,5 @@ +package auth_service.modules.profile.model; + +import java.time.LocalDate; + +public record PublicProfile(long userId, String displayName, LocalDate memberSince) {} diff --git a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java new file mode 100644 index 0000000..e9cc43b --- /dev/null +++ b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java @@ -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 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(); + } +} diff --git a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java new file mode 100644 index 0000000..bd40fed --- /dev/null +++ b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java @@ -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 findPublicProfile(String displayName) { + return publicProfileRepository.findPublicByDisplayName(displayName) + .map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId()))); + } +} diff --git a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java index aaf830e..e2076a1 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java @@ -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; + } } diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java index 21aa8ab..d9fff1b 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index 1719c43..59ffb12 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -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(); } diff --git a/src/main/java/auth_service/modules/user/model/UserSettings.java b/src/main/java/auth_service/modules/user/model/UserSettings.java index c28d175..d7162f0 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -8,4 +8,5 @@ import lombok.Data; public class UserSettings { private String language; private String theme; + private boolean publicProfile; } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index c996637..410b452 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -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); diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index b0f2562..ca08633 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java @@ -23,4 +23,7 @@ public class UserSettingsEntity { @Column(nullable = false) private String theme; + + @Column(name = "public_profile", nullable = false) + private boolean publicProfile; } diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index 8046496..92ce8b3 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -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; } } \ No newline at end of file diff --git a/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java b/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java new file mode 100644 index 0000000..82ab04d --- /dev/null +++ b/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java @@ -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); +} diff --git a/src/main/resources/db/migration/V7__Add_Public_Profile.sql b/src/main/resources/db/migration/V7__Add_Public_Profile.sql new file mode 100644 index 0000000..4d71a39 --- /dev/null +++ b/src/main/resources/db/migration/V7__Add_Public_Profile.sql @@ -0,0 +1,2 @@ +-- Opt-in for the public profile page /u/. Off for everyone until they switch it on. +ALTER TABLE user_settings ADD COLUMN public_profile BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index 7a01a43..a2cdd37 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -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))); + } } diff --git a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java new file mode 100644 index 0000000..c3492d0 --- /dev/null +++ b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java @@ -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); + } +} diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 03e91a4..38df50b 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -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)); + } } diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index 02b7b64..7257ff1 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -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()); + } }