Public Access
feat(profile): role claim in the profile ticket
The public profile ticket now carries the user's role so Core can check ownership of shop items. PLATFORM_ADMIN gets every catalog item for free. The role comes from the users table; a ticket without the claim means USER.
This commit is contained in:
1 parent
6b6ac44551
commit
e72222a37f
9 files changed
+28
-12
No files matched your search
@@ -39,8 +39,9 @@ To Be Played is split into three repositories:
|
|||||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
||||||
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
||||||
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
|
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
|
||||||
`style`. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_`
|
`style`. The ticket also names the user's `role`, so Core can show the free looks of an admin. The ticket's
|
||||||
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` tile is shown),
|
||||||
|
`profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
||||||
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
||||||
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
||||||
A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in
|
A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in
|
||||||
|
|||||||
@@ -85,13 +85,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) {
|
public String generateProfileTicket(long userId, Role role, List<String> profileStats, List<String> sections) {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.header().keyId(jwtConfig.getKid()).and()
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
.issuer(ISSUER)
|
.issuer(ISSUER)
|
||||||
.audience().add(PROFILE_TICKET_AUDIENCE).and()
|
.audience().add(PROFILE_TICKET_AUDIENCE).and()
|
||||||
.subject(PROFILE_TICKET_SUBJECT)
|
.subject(PROFILE_TICKET_SUBJECT)
|
||||||
.claim("profileUserId", String.valueOf(userId))
|
.claim("profileUserId", String.valueOf(userId))
|
||||||
|
.claim(ROLE_CLAIM, role.name())
|
||||||
.claim("profileStats", profileStats)
|
.claim("profileStats", profileStats)
|
||||||
.claim("sections", sections)
|
.claim("sections", sections)
|
||||||
.issuedAt(new Date(System.currentTimeMillis()))
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package auth_service.modules.profile.model;
|
package auth_service.modules.profile.model;
|
||||||
|
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record PublicProfile(long userId, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
|
public record PublicProfile(long userId, Role role, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
|
||||||
|
|
||||||
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
|
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth_service.modules.profile.repo;
|
|||||||
|
|
||||||
import auth_service.modules.profile.model.PublicProfile;
|
import auth_service.modules.profile.model.PublicProfile;
|
||||||
import auth_service.shared.account.ActiveUser;
|
import auth_service.shared.account.ActiveUser;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -30,12 +31,12 @@ public class PublicProfileRepository {
|
|||||||
*/
|
*/
|
||||||
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
|
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
|
SELECT u.id, u.role, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
|
||||||
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
|
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
|
||||||
FROM users u JOIN user_settings s ON s.user_id = u.id
|
FROM users u JOIN user_settings s ON s.user_id = u.id
|
||||||
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
|
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
|
||||||
""".formatted(ActiveUser.of("u")),
|
""".formatted(ActiveUser.of("u")),
|
||||||
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
|
(rs, row) -> new PublicProfile(rs.getLong("id"), Role.parse(rs.getString("role")), rs.getString("display_name"),
|
||||||
rs.getTimestamp("registration_date") != null
|
rs.getTimestamp("registration_date") != null
|
||||||
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
|
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
|
||||||
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
|
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
|
||||||
|
|||||||
@@ -42,6 +42,6 @@ public class PublicProfileService {
|
|||||||
if (!statGroups.isEmpty()) {
|
if (!statGroups.isEmpty()) {
|
||||||
sections.add("STATS");
|
sections.add("STATS");
|
||||||
}
|
}
|
||||||
return profileTicketIssuer.generateProfileTicket(profile.userId(), statGroups, sections);
|
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,5 @@ public interface ProfileTicketIssuer {
|
|||||||
* @param profileStats the stat groups of Core the profile shows
|
* @param profileStats the stat groups of Core the profile shows
|
||||||
* @param sections the parts of the profile Core sends data for
|
* @param sections the parts of the profile Core sends data for
|
||||||
*/
|
*/
|
||||||
String generateProfileTicket(long userId, List<String> profileStats, List<String> sections);
|
String generateProfileTicket(long userId, Role role, List<String> profileStats, List<String> sections);
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
|
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
|
||||||
var ticket = jwtService.generateProfileTicket(42L, List.of(), List.of());
|
var ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of());
|
||||||
|
|
||||||
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
||||||
assertEquals(List.of("tbp-public-profile"), List.copyOf(claims.getAudience()));
|
assertEquals(List.of("tbp-public-profile"), List.copyOf(claims.getAudience()));
|
||||||
@@ -123,9 +123,17 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s");
|
assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateProfileTicket_CarriesTheRole() {
|
||||||
|
String ticket = jwtService.generateProfileTicket(42L, Role.PLATFORM_ADMIN, List.of(), List.of());
|
||||||
|
|
||||||
|
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
||||||
|
assertEquals("PLATFORM_ADMIN", claims.get("role", String.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateProfileTicket_CarriesTheSharedStatGroups() {
|
void generateProfileTicket_CarriesTheSharedStatGroups() {
|
||||||
String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND"), List.of("PLAYING", "STATS"));
|
String ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of("PILE", "TREND"), List.of("PLAYING", "STATS"));
|
||||||
|
|
||||||
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
||||||
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
|
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
|
||||||
@@ -134,7 +142,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_ProfileTicketIsNoLoginToken() {
|
void parseLoginToken_ProfileTicketIsNoLoginToken() {
|
||||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of(), List.of())));
|
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -218,7 +226,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals(ContractFixture.token("login"),
|
assertEquals(ContractFixture.token("login"),
|
||||||
payload(jwtService.generateToken("42", 76561198000000001L, Role.USER)));
|
payload(jwtService.generateToken("42", 76561198000000001L, Role.USER)));
|
||||||
assertEquals(ContractFixture.token("publicProfile"),
|
assertEquals(ContractFixture.token("publicProfile"),
|
||||||
payload(jwtService.generateProfileTicket(42L, List.of("OVERVIEW"), List.of("SUMMARY"))));
|
payload(jwtService.generateProfileTicket(42L, Role.USER, List.of("OVERVIEW"), List.of("SUMMARY"))));
|
||||||
assertEquals(ContractFixture.token("service"), payload(jwtService.generateServiceToken()));
|
assertEquals(ContractFixture.token("service"), payload(jwtService.generateServiceToken()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
|
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
|
||||||
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class));
|
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class));
|
||||||
assertEquals(List.of(), claims.get("profileStats", List.class));
|
assertEquals(List.of(), claims.get("profileStats", List.class));
|
||||||
|
assertEquals("USER", claims.get("role", String.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -120,6 +121,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
|
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
|
||||||
assertEquals(List.of("PLAYING"), claims.get("sections", List.class));
|
assertEquals(List.of("PLAYING"), claims.get("sections", List.class));
|
||||||
assertEquals(List.of(), claims.get("profileStats", List.class));
|
assertEquals(List.of(), claims.get("profileStats", List.class));
|
||||||
|
assertEquals("USER", claims.get("role", String.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"aud": ["tbp-public-profile"],
|
"aud": ["tbp-public-profile"],
|
||||||
"sub": "public-profile",
|
"sub": "public-profile",
|
||||||
"profileUserId": "42",
|
"profileUserId": "42",
|
||||||
|
"role": "USER",
|
||||||
"profileStats": ["OVERVIEW"],
|
"profileStats": ["OVERVIEW"],
|
||||||
"sections": ["SUMMARY"]
|
"sections": ["SUMMARY"]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user