Skip to content

Commit

Permalink
Feature: voted on support
Browse files Browse the repository at this point in the history
  • Loading branch information
Mateusz Czeladka committed Sep 18, 2023
1 parent caf7201 commit 3148bfc
Show file tree
Hide file tree
Showing 12 changed files with 266 additions and 199 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// SECURED by Web3 auth
.requestMatchers(new AntPathRequestMatcher("/api/vote/cast", POST.name())).authenticated()
// SECURED by JWT auth
.requestMatchers(new AntPathRequestMatcher("/api/vote/casting-available/**", HEAD.name())).authenticated()
.requestMatchers(new AntPathRequestMatcher("/api/vote/vote-changing-available/**", HEAD.name())).authenticated()

// without auth
.requestMatchers(new AntPathRequestMatcher("/api/leaderboard/**", GET.name())).permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.cardano.foundation.voting.domain;

public record CategoryProposalPair(String categoryId, String proposalId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@
import org.cardano.foundation.voting.domain.web3.Web3Action;

import java.util.List;
import java.util.stream.Stream;

import static org.cardano.foundation.voting.domain.web3.Web3Action.IS_VOTE_CASTING_ALLOWED;
import static org.cardano.foundation.voting.domain.web3.Web3Action.VIEW_VOTE_RECEIPT;
import static org.cardano.foundation.voting.domain.web3.Web3Action.*;

public enum Role {

VOTER(List.of(VIEW_VOTE_RECEIPT, IS_VOTE_CASTING_ALLOWED));
VOTER(List.of(VIEW_VOTE_RECEIPT, IS_VOTE_CASTING_ALLOWED, VOTED_ON));

private final List<Web3Action> allowedActions;

Role(List<Web3Action> allowedActions) {
this.allowedActions = allowedActions;
}

public static String supportedRoles() {
return String.join(", ", Stream.of(Role.values()).map(Role::name).toList());
}

public List<Web3Action> allowedActions() {
return allowedActions;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

public enum Web3Action {

CAST_VOTE, VIEW_VOTE_RECEIPT, LOGIN, IS_VOTE_CASTING_ALLOWED
CAST_VOTE, // casting vote

VIEW_VOTE_RECEIPT, // obtaining vote receipt

LOGIN, // JWT login based on WEB3 login

IS_VOTE_CASTING_ALLOWED, // checking if vote casting is still allowed

IS_VOTE_CHANGING_ALLOWED, // checking if vote casting changing

VOTED_ON // getting list of categories and proposals user already voted on

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
@Repository
public interface VoteRepository extends JpaRepository<Vote, String> {

@Query("SELECT v.categoryId as categoryId, v.proposalId as proposalId FROM Vote v WHERE v.eventId = :eventId AND v.stakeAddress = :stakeAddress ORDER BY v.votedAtSlot")
List<CategoryProposalProjection> getVotedOn(@Param("eventId") String eventId, @Param("stakeAddress") String stakeAddress);

@Query("SELECT v FROM Vote v WHERE v.eventId = :eventId ORDER BY v.votedAtSlot, v.createdAt DESC")
List<CompactVote> findAllCompactVotesByEventId(@Param("eventId") String eventId);

Expand All @@ -27,6 +30,14 @@ public interface VoteRepository extends JpaRepository<Vote, String> {
@Query("SELECT v.categoryId as categoryId, v.proposalId AS proposalId, COUNT(v) AS totalVoteCount, SUM(v.votingPower) AS totalVotingPower FROM Vote v WHERE v.eventId = :eventId AND v.categoryId = :categoryId GROUP BY proposalId")
List<CategoryLevelStats> getCategoryLevelStats(@Param("eventId") String eventId, @Param("categoryId") String categoryId);

interface CategoryProposalProjection {

String getCategoryId();

String getProposalId();

}

interface HighLevelEventVoteCount {

@Nullable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class LoginResource {
@RequestMapping(value = "/login", method = GET, produces = "application/json")
@Timed(value = "resource.auth.login", histogram = true)
public ResponseEntity<?> login(Authentication authentication) {
if (!(authentication instanceof Web3AuthenticationToken)) {
if (!(authentication instanceof Web3AuthenticationToken web3AuthenticationToken)) {
var problem = Problem.builder()
.withTitle("WEB3_AUTH_REQUIRED")
.withDetail("CIP-93 auth headers tokens needed!")
Expand All @@ -35,7 +35,7 @@ public ResponseEntity<?> login(Authentication authentication) {
return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

return loginService.login((Web3AuthenticationToken) authentication)
return loginService.login(web3AuthenticationToken)
.fold(problem -> ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem),
ResponseEntity::ok
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import org.springframework.web.bind.annotation.RestController;
import org.zalando.problem.Problem;

import java.util.Optional;

import static org.springframework.http.HttpStatus.NOT_ACCEPTABLE;
import static org.springframework.web.bind.annotation.RequestMethod.*;
import static org.zalando.problem.Status.BAD_REQUEST;
Expand All @@ -25,12 +27,49 @@ public class VoteResource {

private final VoteService voteService;

@RequestMapping(value = "/voted-on/{eventId}", method = GET, produces = "application/json")
@Timed(value = "resource.vote.votedOn", histogram = true)
public ResponseEntity<?> votedOn(@PathVariable(value = "eventId", required = false) Optional<String> maybeEventId,
Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
var problem = Problem.builder()
.withTitle("JWT_REQUIRED")
.withDetail("JWT auth Bearer Auth token needed!")
.withStatus(BAD_REQUEST)
.build();

return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

if (maybeEventId.isPresent() && !maybeEventId.orElseThrow().equals(jwtAuth.eventDetails().id())) {
var problem = Problem.builder()
.withTitle("EVENT_ID_MISMATCH")
.withDetail("Event id in path and in JWT token do not match!")
.withStatus(BAD_REQUEST)
.build();

return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

return voteService.getVotedOn(jwtAuth)
.fold(problem -> {
log.warn("Vote get voted on failed, problem:{}", problem);

return ResponseEntity
.status(problem.getStatus().getStatusCode())
.body(problem);
},
categoryProposalPairs -> {
return ResponseEntity.ok().body(categoryProposalPairs);
});
}

@RequestMapping(value = "/cast", method = POST, produces = "application/json")
@Timed(value = "resource.vote.cast", histogram = true)
public ResponseEntity<?> castVote(Authentication authentication) {
log.info("Casting vote...");

if (!(authentication instanceof Web3AuthenticationToken)) {
if (!(authentication instanceof Web3AuthenticationToken web3Auth)) {
var problem = Problem.builder()
.withTitle("WEB3_AUTH_REQUIRED")
.withDetail("CIP-93 auth headers tokens needed!")
Expand All @@ -40,7 +79,7 @@ public ResponseEntity<?> castVote(Authentication authentication) {
return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

return voteService.castVote((Web3AuthenticationToken) authentication)
return voteService.castVote(web3Auth)
.fold(problem -> {
log.warn("Vote cast failed, problem:{}", problem);

Expand All @@ -59,7 +98,7 @@ public ResponseEntity<?> castVote(Authentication authentication) {
@Timed(value = "resource.vote.receipt.web3", histogram = true)
public ResponseEntity<?> getVoteReceipt(Authentication authentication) {

if (!(authentication instanceof Web3AuthenticationToken)) {
if (!(authentication instanceof Web3AuthenticationToken web3Auth)) {
var problem = Problem.builder()
.withTitle("WEB3_AUTH_REQUIRED")
.withDetail("CIP-93 auth headers tokens needed!")
Expand All @@ -69,19 +108,19 @@ public ResponseEntity<?> getVoteReceipt(Authentication authentication) {
return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

return voteService.voteReceipt((Web3AuthenticationToken) authentication)
return voteService.voteReceipt(web3Auth)
.fold(problem -> ResponseEntity
.status(problem.getStatus().getStatusCode())
.body(problem),
voteReceipt -> ResponseEntity.ok().body(voteReceipt));
}

@RequestMapping(value = "/receipt/{eventId}/{categoryId}", method = GET, produces = "application/json")
@RequestMapping(value = "/receipt/{maybeEventId}/{categoryId}", method = GET, produces = "application/json")
@Timed(value = "resource.vote.receipt.jwt", histogram = true)
public ResponseEntity<?> getVoteReceipt(@PathVariable("eventId") String eventId,
public ResponseEntity<?> getVoteReceipt(@PathVariable(value = "eventId", required = false) Optional<String> maybeEventId,
@PathVariable("categoryId") String categoryId,
Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken)) {
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
var problem = Problem.builder()
.withTitle("JWT_REQUIRED")
.withDetail("JWT auth token needed!")
Expand All @@ -91,21 +130,29 @@ public ResponseEntity<?> getVoteReceipt(@PathVariable("eventId") String eventId,
return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

var jwtAuth = (JwtAuthenticationToken) authentication;
if (maybeEventId.isPresent() && !maybeEventId.orElseThrow().equals(jwtAuth.eventDetails().id())) {
var problem = Problem.builder()
.withTitle("EVENT_ID_MISMATCH")
.withDetail("Event id in path and in JWT token do not match!")
.withStatus(BAD_REQUEST)
.build();

return voteService.voteReceipt(jwtAuth, eventId, categoryId)
return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

return voteService.voteReceipt(categoryId, jwtAuth)
.fold(problem -> ResponseEntity
.status(problem.getStatus().getStatusCode())
.body(problem),
voteReceipt -> ResponseEntity.ok().body(voteReceipt));
}

@RequestMapping(value = "/casting-available/{eventId}/{voteId}", method = HEAD, produces = "application/json")
@RequestMapping(value = "/vote-changing-available/{eventId}/{voteId}", method = HEAD, produces = "application/json")
@Timed(value = "resource.voteId.receipt", histogram = true)
public ResponseEntity<?> isVoteCastingStillPossible(@PathVariable("eventId") String eventId,
@PathVariable("voteId") String voteId,
Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken)) {
public ResponseEntity<?> isVoteChangingAvailable(@PathVariable(value = "eventId", required = false) Optional<String> maybeEventId,
@PathVariable("voteId") String voteId,
Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
var problem = Problem.builder()
.withTitle("JWT_REQUIRED")
.withDetail("JWT auth Bearer Auth token needed!")
Expand All @@ -115,9 +162,17 @@ public ResponseEntity<?> isVoteCastingStillPossible(@PathVariable("eventId") Str
return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

var jwtAuth = (JwtAuthenticationToken) authentication;
if (maybeEventId.isPresent() && maybeEventId.orElseThrow().equals(jwtAuth.eventDetails().id())) {
var problem = Problem.builder()
.withTitle("EVENT_ID_MISMATCH")
.withDetail("Event id in path and in JWT token do not match!")
.withStatus(BAD_REQUEST)
.build();

return ResponseEntity.status(problem.getStatus().getStatusCode()).body(problem);
}

return voteService.isVoteCastingStillPossible(jwtAuth, eventId, voteId)
return voteService.isVoteChangingPossible(voteId, jwtAuth)
.fold(problem -> ResponseEntity
.status(problem.getStatus().getStatusCode())
.body(problem),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,53 @@
package org.cardano.foundation.voting.service.auth.jwt;

import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import lombok.experimental.Accessors;
import org.cardano.foundation.voting.client.ChainFollowerClient;
import org.cardano.foundation.voting.domain.Role;
import org.cardano.foundation.voting.domain.web3.Web3Action;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import javax.annotation.Nullable;
import java.util.Collection;

@Setter
@Getter
@Accessors(fluent = true)
public class JwtAuthenticationToken extends AbstractAuthenticationToken {

private final JwtPrincipal principal;

public JwtAuthenticationToken(JwtPrincipal principal, Collection<? extends GrantedAuthority> authorities) {
private ChainFollowerClient.EventDetailsResponse eventDetails;

private String stakeAddress;

private Role role;

public JwtAuthenticationToken(JwtPrincipal principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.setAuthenticated(true);
}

public boolean isActionAllowed(Web3Action action) {
var allowedRoles = role.allowedActions();

return allowedRoles.contains(action);
}

public boolean isActionNotAllowed(Web3Action action) {
return !isActionAllowed(action);
}

@SneakyThrows
public String getStakeAddress() {
return stakeAddress;
}

@Override
public Object getDetails() {
return principal.getSignedJWT();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.cardano.foundation.voting.client.ChainFollowerClient;
import org.cardano.foundation.voting.domain.Role;
import org.cardano.foundation.voting.service.auth.LoginSystemDetector;
import org.cardano.foundation.voting.utils.Enums;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
Expand All @@ -22,12 +25,15 @@
import static com.google.common.net.HttpHeaders.AUTHORIZATION;
import static org.cardano.foundation.voting.service.auth.LoginSystem.JWT;
import static org.zalando.problem.Status.BAD_REQUEST;
import static org.zalando.problem.Status.INTERNAL_SERVER_ERROR;

@Component
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

private final ChainFollowerClient chainFollowerClient;

private final JwtService jwtService;

private final LoginSystemDetector loginSystemDetector;
Expand Down Expand Up @@ -63,9 +69,44 @@ protected void doFilterInternal(HttpServletRequest request,
var signedJwt = verificationResultE.get();

try {
var role = signedJwt.getJWTClaimsSet().getStringClaim("role");
var jwtClaimsSet = signedJwt.getJWTClaimsSet();
var eventId = jwtClaimsSet.getStringClaim("eventId");
var stakeAddress = jwtClaimsSet.getStringClaim("stakeAddress");
var role = Enums.getIfPresent(Role.class, jwtClaimsSet.getStringClaim("role")).orElseThrow();
var principal = new JwtPrincipal(signedJwt);
var authorities = List.of(new SimpleGrantedAuthority(role.name()));

var eventDetailsE = chainFollowerClient.getEventDetails(eventId);
if (eventDetailsE.isEmpty()) {
var problem = Problem.builder()
.withTitle("ERROR_GETTING_EVENT_DETAILS")
.withDetail("Unable to get eventDetails details from chain-tip follower service, eventDetails:" + eventId)
.withStatus(INTERNAL_SERVER_ERROR)
.build();

sendBackProblem(response, problem);
return;
}

var maybeEventDetails = eventDetailsE.get();
if (maybeEventDetails.isEmpty()) {
var problem = Problem.builder()
.withTitle("EVENT_NOT_FOUND")
.withDetail("Event not found, id:" + eventId)
.withStatus(BAD_REQUEST)
.build();

sendBackProblem(response, problem);
return;
}
var eventDetails = maybeEventDetails.orElseThrow();

var authentication = new JwtAuthenticationToken(principal, authorities)
.eventDetails(eventDetails)
.role(role)
.stakeAddress(stakeAddress)
;

var authentication = new JwtAuthenticationToken(new JwtPrincipal(signedJwt), List.of(new SimpleGrantedAuthority(role)));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

SecurityContextHolder.getContext().setAuthentication(authentication);
Expand Down
Loading

0 comments on commit 3148bfc

Please sign in to comment.