From a07379ebadcf9925520251cc8dd5282f8470304b Mon Sep 17 00:00:00 2001 From: Armin Friedl Date: Tue, 21 Jul 2020 21:24:10 +0200 Subject: [PATCH] Generate derived authentication tokens Can be used to authorize download urls via query parameter since the tokens can be used only once. --- examples/querysheet.http | 5 + .../controller/AuthenticationController.java | 25 +++ .../persistence/entities/TokenEntity.java | 38 ++++ .../repositories/TokenRepository.java | 8 + .../fling/security/FlingAuthorities.java | 2 +- .../security/FlingWebSecurityConfigurer.java | 25 ++- .../security/authentication/FlingToken.java | 26 ++- .../FlingAdminAuthority.java | 2 +- .../{ => authorities}/FlingUserAuthority.java | 2 +- .../BearerAuthenticationFilter.java} | 13 +- .../filter/TokenAuthenticationFilter.java | 59 ++++++ .../friedl/fling/service/ArtifactService.java | 2 +- .../fling/service/AuthenticationService.java | 68 ++++++- .../fling/service/AuthorizationService.java | 4 +- .../friedl/fling/service/FlingService.java | 4 +- .../AuthenticationControllerTest.java | 1 + .../service/AuthenticationServiceTest.java | 141 ++++++++++++-- .../service/AuthorizationServiceTest.java | 26 ++- .../src/components/admin/FlingArtifacts.jsx | 182 +++++++++--------- web/fling/src/redux/reducers/artifacts.js | 39 ---- 20 files changed, 483 insertions(+), 189 deletions(-) create mode 100644 service/fling/src/main/java/net/friedl/fling/persistence/entities/TokenEntity.java create mode 100644 service/fling/src/main/java/net/friedl/fling/persistence/repositories/TokenRepository.java rename service/fling/src/main/java/net/friedl/fling/security/authentication/{ => authorities}/FlingAdminAuthority.java (85%) rename service/fling/src/main/java/net/friedl/fling/security/authentication/{ => authorities}/FlingUserAuthority.java (89%) rename service/fling/src/main/java/net/friedl/fling/security/authentication/{JwtAuthenticationFilter.java => filter/BearerAuthenticationFilter.java} (79%) create mode 100644 service/fling/src/main/java/net/friedl/fling/security/authentication/filter/TokenAuthenticationFilter.java delete mode 100644 web/fling/src/redux/reducers/artifacts.js diff --git a/examples/querysheet.http b/examples/querysheet.http index a9e3a3c..cb84cd8 100644 --- a/examples/querysheet.http +++ b/examples/querysheet.http @@ -47,6 +47,11 @@ Content-Type: application/json :token {"name": "Fling from querysheet with Auth and very long name", "expirationClicks": 12, "shared": true, "authCode": "abc"} +# GET derived auth token +GET http://localhost:8080/api/auth/derive +Content-Type: application/json +:token + # :flingId = dfc208a3-5924-43b4-aa6a-c263541dca5e diff --git a/service/fling/src/main/java/net/friedl/fling/controller/AuthenticationController.java b/service/fling/src/main/java/net/friedl/fling/controller/AuthenticationController.java index ec896fe..47be787 100644 --- a/service/fling/src/main/java/net/friedl/fling/controller/AuthenticationController.java +++ b/service/fling/src/main/java/net/friedl/fling/controller/AuthenticationController.java @@ -1,17 +1,23 @@ package net.friedl.fling.controller; +import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import net.friedl.fling.model.dto.AdminAuthDto; import net.friedl.fling.model.dto.UserAuthDto; +import net.friedl.fling.security.FlingWebSecurityConfigurer; import net.friedl.fling.service.AuthenticationService; @RestController @@ -50,4 +56,23 @@ public class AuthenticationController { return authenticationService.authenticate(userAuthDto) .orElseThrow(() -> new AccessDeniedException("Wrong username or password")); } + + //@formatter:off + /** + * Note that this endpoint is not protected. But the token will only get authority of the + * authenticated user. + * @see FlingWebSecurityConfigurer + * @see AuthenticationService + */ + @Operation(description = "Generate a derived token from the current authorization") + @ApiResponse(responseCode = "200", description = "Token impersonating the user") + @SecurityRequirement(name = "bearer") + @GetMapping("/derive") + public String deriveToken( + @Parameter(allowEmptyValue = true, description = "Token can only be used for authorizing one request. Defaults to true") + @RequestParam Optional singleUse) + { + return authenticationService.deriveToken(singleUse.orElse(true)); + } + //@formatter:on } diff --git a/service/fling/src/main/java/net/friedl/fling/persistence/entities/TokenEntity.java b/service/fling/src/main/java/net/friedl/fling/persistence/entities/TokenEntity.java new file mode 100644 index 0000000..33e0d98 --- /dev/null +++ b/service/fling/src/main/java/net/friedl/fling/persistence/entities/TokenEntity.java @@ -0,0 +1,38 @@ +package net.friedl.fling.persistence.entities; + +import java.time.Instant; +import java.util.UUID; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Version; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "Token") +@Getter +@Setter +public class TokenEntity { + @Id + private UUID id; // Note that this is not generated to ensure randomness independent from the + // persistence provider + + @Column(nullable = false) + private Boolean singleUse = true; + + @Column(nullable = false) + private String token; // JWT token this token is derived from + + @CreationTimestamp + private Instant creationTime; + + @UpdateTimestamp + private Instant updateTime; + + @Version + private Long version; +} diff --git a/service/fling/src/main/java/net/friedl/fling/persistence/repositories/TokenRepository.java b/service/fling/src/main/java/net/friedl/fling/persistence/repositories/TokenRepository.java new file mode 100644 index 0000000..07837ea --- /dev/null +++ b/service/fling/src/main/java/net/friedl/fling/persistence/repositories/TokenRepository.java @@ -0,0 +1,8 @@ +package net.friedl.fling.persistence.repositories; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import net.friedl.fling.persistence.entities.TokenEntity; + +public interface TokenRepository extends JpaRepository { +} diff --git a/service/fling/src/main/java/net/friedl/fling/security/FlingAuthorities.java b/service/fling/src/main/java/net/friedl/fling/security/FlingAuthorities.java index 3dd4698..3742de5 100644 --- a/service/fling/src/main/java/net/friedl/fling/security/FlingAuthorities.java +++ b/service/fling/src/main/java/net/friedl/fling/security/FlingAuthorities.java @@ -4,7 +4,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; public enum FlingAuthorities { - FLING_ADMIN("admin"), FLING_USER("user"); + FLING_ADMIN("admin"), FLING_USER("user"), FLING_TOKEN("token"); String authority; diff --git a/service/fling/src/main/java/net/friedl/fling/security/FlingWebSecurityConfigurer.java b/service/fling/src/main/java/net/friedl/fling/security/FlingWebSecurityConfigurer.java index 180d2cf..fe6ed9d 100644 --- a/service/fling/src/main/java/net/friedl/fling/security/FlingWebSecurityConfigurer.java +++ b/service/fling/src/main/java/net/friedl/fling/security/FlingWebSecurityConfigurer.java @@ -1,6 +1,7 @@ package net.friedl.fling.security; import static net.friedl.fling.security.FlingAuthorities.FLING_ADMIN; +import static net.friedl.fling.security.FlingAuthorities.FLING_USER; import static org.springframework.security.config.Customizer.withDefaults; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; @@ -18,8 +19,8 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import net.friedl.fling.FlingSecurityConfiguration; -import net.friedl.fling.security.authentication.JwtAuthenticationFilter; +import net.friedl.fling.security.authentication.filter.BearerAuthenticationFilter; +import net.friedl.fling.security.authentication.filter.TokenAuthenticationFilter; import net.friedl.fling.service.AuthorizationService; @Slf4j @@ -30,15 +31,18 @@ import net.friedl.fling.service.AuthorizationService; public class FlingWebSecurityConfigurer extends WebSecurityConfigurerAdapter { private List allowedOrigins; - private JwtAuthenticationFilter jwtAuthenticationFilter; + private TokenAuthenticationFilter tokenAuthenticationFilter; + private BearerAuthenticationFilter bearerAuthenticationFilter; private AuthorizationService authorizationService; @Autowired - public FlingWebSecurityConfigurer(JwtAuthenticationFilter jwtAuthenticationFilter, - AuthorizationService authorizationService, - FlingSecurityConfiguration securityConfiguraiton) { + public FlingWebSecurityConfigurer( + TokenAuthenticationFilter tokenAuthenticationFilter, + BearerAuthenticationFilter bearerAuthenticationFilter, + AuthorizationService authorizationService) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.tokenAuthenticationFilter = tokenAuthenticationFilter; + this.bearerAuthenticationFilter = bearerAuthenticationFilter; this.authorizationService = authorizationService; } @@ -52,7 +56,8 @@ public class FlingWebSecurityConfigurer extends WebSecurityConfigurerAdapter { /**********************************************/ /** Authentication Interceptor Configuration **/ /**********************************************/ - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(bearerAuthenticationFilter, TokenAuthenticationFilter.class) // Do not keep authorization token in session. This would interfere with bearer authentication // in that it is possible to authenticate without a bearer token if the session is kept. // Turn off this confusing and non-obvious behavior. @@ -68,6 +73,10 @@ public class FlingWebSecurityConfigurer extends WebSecurityConfigurerAdapter { /**********************************/ /** Authorization for: /api/auth **/ /**********************************/ + .authorizeRequests() + .antMatchers("/api/auth/derive") + .hasAnyAuthority(FLING_ADMIN.getAuthority(), FLING_USER.getAuthority()) + .and() .authorizeRequests() .antMatchers("/api/auth/**") .permitAll() diff --git a/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingToken.java b/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingToken.java index e731e35..41f0565 100644 --- a/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingToken.java +++ b/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingToken.java @@ -4,25 +4,31 @@ import java.util.List; import java.util.UUID; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +import net.friedl.fling.security.authentication.authorities.FlingAdminAuthority; +import net.friedl.fling.security.authentication.authorities.FlingUserAuthority; public class FlingToken extends AbstractAuthenticationToken { private static final long serialVersionUID = -1112423505610346583L; - private String jwtToken; + private String token; - public FlingToken(GrantedAuthority authority, String jwtToken) { - super(List.of(authority)); - this.jwtToken = jwtToken; + public FlingToken(List authorities, String token) { + super(authorities); + this.token = token; } public boolean authorizedForFling(UUID id) { for (GrantedAuthority grantedAuthority : getAuthorities()) { - if (grantedAuthority instanceof FlingAdminAuthority) return true; + if (grantedAuthority instanceof FlingAdminAuthority) { + return true; + } - if (!(grantedAuthority instanceof FlingUserAuthority)) continue; - - UUID grantedFlingId = ((FlingUserAuthority) grantedAuthority).getFlingId(); - if (grantedFlingId.equals(id)) return true; + if (grantedAuthority instanceof FlingUserAuthority) { + UUID grantedFlingId = ((FlingUserAuthority) grantedAuthority).getFlingId(); + if (grantedFlingId.equals(id)) { + return true; + } + } } return false; @@ -30,7 +36,7 @@ public class FlingToken extends AbstractAuthenticationToken { @Override public String getCredentials() { - return this.jwtToken; + return this.token; } @Override diff --git a/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingAdminAuthority.java b/service/fling/src/main/java/net/friedl/fling/security/authentication/authorities/FlingAdminAuthority.java similarity index 85% rename from service/fling/src/main/java/net/friedl/fling/security/authentication/FlingAdminAuthority.java rename to service/fling/src/main/java/net/friedl/fling/security/authentication/authorities/FlingAdminAuthority.java index 1784d56..5251b41 100644 --- a/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingAdminAuthority.java +++ b/service/fling/src/main/java/net/friedl/fling/security/authentication/authorities/FlingAdminAuthority.java @@ -1,4 +1,4 @@ -package net.friedl.fling.security.authentication; +package net.friedl.fling.security.authentication.authorities; import org.springframework.security.core.GrantedAuthority; import net.friedl.fling.security.FlingAuthorities; diff --git a/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingUserAuthority.java b/service/fling/src/main/java/net/friedl/fling/security/authentication/authorities/FlingUserAuthority.java similarity index 89% rename from service/fling/src/main/java/net/friedl/fling/security/authentication/FlingUserAuthority.java rename to service/fling/src/main/java/net/friedl/fling/security/authentication/authorities/FlingUserAuthority.java index 566eaf1..dccd644 100644 --- a/service/fling/src/main/java/net/friedl/fling/security/authentication/FlingUserAuthority.java +++ b/service/fling/src/main/java/net/friedl/fling/security/authentication/authorities/FlingUserAuthority.java @@ -1,4 +1,4 @@ -package net.friedl.fling.security.authentication; +package net.friedl.fling.security.authentication.authorities; import java.util.UUID; import org.springframework.security.core.GrantedAuthority; diff --git a/service/fling/src/main/java/net/friedl/fling/security/authentication/JwtAuthenticationFilter.java b/service/fling/src/main/java/net/friedl/fling/security/authentication/filter/BearerAuthenticationFilter.java similarity index 79% rename from service/fling/src/main/java/net/friedl/fling/security/authentication/JwtAuthenticationFilter.java rename to service/fling/src/main/java/net/friedl/fling/security/authentication/filter/BearerAuthenticationFilter.java index b4e5dc4..603178a 100644 --- a/service/fling/src/main/java/net/friedl/fling/security/authentication/JwtAuthenticationFilter.java +++ b/service/fling/src/main/java/net/friedl/fling/security/authentication/filter/BearerAuthenticationFilter.java @@ -1,4 +1,4 @@ -package net.friedl.fling.security.authentication; +package net.friedl.fling.security.authentication.filter; import java.io.IOException; import java.util.stream.Collectors; @@ -13,18 +13,19 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import lombok.extern.slf4j.Slf4j; +import net.friedl.fling.security.authentication.FlingToken; import net.friedl.fling.service.AuthenticationService; @Slf4j @Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { +public class BearerAuthenticationFilter extends OncePerRequestFilter { private static final String TOKEN_PREFIX = "Bearer "; private static final String HEADER_STRING = "Authorization"; private AuthenticationService authenticationService; @Autowired - public JwtAuthenticationFilter(AuthenticationService authenticationService) { + public BearerAuthenticationFilter(AuthenticationService authenticationService) { this.authenticationService = authenticationService; } @@ -37,7 +38,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (header == null || !header.startsWith(TOKEN_PREFIX)) { log.info("Anonymous request for {} {}{}", request.getMethod(), request.getRequestURL(), - request.getQueryString() != null ? "?"+request.getQueryString(): ""); + request.getQueryString() != null ? "?" + request.getQueryString() : ""); filterChain.doFilter(request, response); return; } @@ -48,8 +49,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (securityContext.getAuthentication() == null) { log.info("Authenticating request for {} {}{}", request.getMethod(), request.getRequestURL(), - request.getQueryString() != null ? "?"+request.getQueryString(): ""); - FlingToken token = authenticationService.parseAuthentication(authToken); + request.getQueryString() != null ? "?" + request.getQueryString() : ""); + FlingToken token = authenticationService.parseJwtAuthentication(authToken); log.info("Authenticated as {}", token.getAuthorities().stream() .map(GrantedAuthority::getAuthority).collect(Collectors.joining(","))); securityContext.setAuthentication(token); diff --git a/service/fling/src/main/java/net/friedl/fling/security/authentication/filter/TokenAuthenticationFilter.java b/service/fling/src/main/java/net/friedl/fling/security/authentication/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..5650c05 --- /dev/null +++ b/service/fling/src/main/java/net/friedl/fling/security/authentication/filter/TokenAuthenticationFilter.java @@ -0,0 +1,59 @@ +package net.friedl.fling.security.authentication.filter; + +import java.io.IOException; +import java.util.stream.Collectors; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import lombok.extern.slf4j.Slf4j; +import net.friedl.fling.security.authentication.FlingToken; +import net.friedl.fling.service.AuthenticationService; + +@Slf4j +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + private AuthenticationService authenticationService; + + @Autowired + public TokenAuthenticationFilter(AuthenticationService authenticationService) { + this.authenticationService = authenticationService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String derivedToken = request.getParameter("derivedtoken"); + if (derivedToken == null) { + log.info("No derived token in request for {} {}{}", request.getMethod(), + request.getRequestURL(), + request.getQueryString() != null ? "?" + request.getQueryString() : ""); + + filterChain.doFilter(request, response); + return; + } + + SecurityContext securityContext = SecurityContextHolder.getContext(); + if (securityContext.getAuthentication() == null) { + log.info("Authenticating request for {} {}{}", request.getMethod(), request.getRequestURL(), + request.getQueryString() != null ? "?" + request.getQueryString() : ""); + + FlingToken token = authenticationService.parseDerivedToken(derivedToken); + + log.info("Authenticated as {}", token.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).collect(Collectors.joining(","))); + + securityContext.setAuthentication(token); + } + + filterChain.doFilter(request, response); + } + +} diff --git a/service/fling/src/main/java/net/friedl/fling/service/ArtifactService.java b/service/fling/src/main/java/net/friedl/fling/service/ArtifactService.java index b839c2b..e9adf61 100644 --- a/service/fling/src/main/java/net/friedl/fling/service/ArtifactService.java +++ b/service/fling/src/main/java/net/friedl/fling/service/ArtifactService.java @@ -56,7 +56,7 @@ public class ArtifactService { */ public ArtifactDto create(UUID flingId, ArtifactDto artifactDto) { FlingEntity flingEntity = flingRepository.getOne(flingId); - + log.debug("Creating new ArtifactEntity for ArtifactDto[.path={}]", artifactDto.getPath()); ArtifactEntity artifactEntity = artifactMapper.map(artifactDto); artifactEntity.setFling(flingEntity); diff --git a/service/fling/src/main/java/net/friedl/fling/service/AuthenticationService.java b/service/fling/src/main/java/net/friedl/fling/service/AuthenticationService.java index 71038aa..e15fb73 100644 --- a/service/fling/src/main/java/net/friedl/fling/service/AuthenticationService.java +++ b/service/fling/src/main/java/net/friedl/fling/service/AuthenticationService.java @@ -3,12 +3,16 @@ package net.friedl.fling.service; import java.security.Key; import java.time.Instant; import java.util.Date; +import java.util.List; import java.util.Optional; import java.util.UUID; import javax.persistence.EntityNotFoundException; +import javax.transaction.Transactional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import io.jsonwebtoken.Claims; @@ -19,10 +23,12 @@ import lombok.extern.slf4j.Slf4j; import net.friedl.fling.model.dto.AdminAuthDto; import net.friedl.fling.model.dto.UserAuthDto; import net.friedl.fling.persistence.entities.FlingEntity; +import net.friedl.fling.persistence.entities.TokenEntity; import net.friedl.fling.persistence.repositories.FlingRepository; -import net.friedl.fling.security.authentication.FlingAdminAuthority; +import net.friedl.fling.persistence.repositories.TokenRepository; import net.friedl.fling.security.authentication.FlingToken; -import net.friedl.fling.security.authentication.FlingUserAuthority; +import net.friedl.fling.security.authentication.authorities.FlingAdminAuthority; +import net.friedl.fling.security.authentication.authorities.FlingUserAuthority; @Slf4j @Service @@ -30,6 +36,7 @@ public class AuthenticationService { private JwtParser jwtParser; private Key jwtSigningKey; private FlingRepository flingRepository; + private TokenRepository tokenRepository; private PasswordEncoder passwordEncoder; @Value("${fling.security.admin-name}") @@ -41,12 +48,14 @@ public class AuthenticationService { @Autowired public AuthenticationService(JwtParser jwtParser, Key jwtSigningKey, - PasswordEncoder passwordEncoder, FlingRepository flingRepository) { + PasswordEncoder passwordEncoder, FlingRepository flingRepository, + TokenRepository tokenRepository) { this.jwtParser = jwtParser; this.jwtSigningKey = jwtSigningKey; this.passwordEncoder = passwordEncoder; this.flingRepository = flingRepository; + this.tokenRepository = tokenRepository; } public Optional authenticate(AdminAuthDto adminAuth) { @@ -71,7 +80,9 @@ public class AuthenticationService { public Optional authenticate(UserAuthDto userAuth) { log.info("Authenticating for fling [.shareId={}]", userAuth.getShareId()); FlingEntity flingEntity = flingRepository.findByShareId(userAuth.getShareId()); - if(flingEntity == null) { throw new EntityNotFoundException("No entity for shareId="+userAuth.getShareId()); } + if (flingEntity == null) { + throw new EntityNotFoundException("No entity for shareId=" + userAuth.getShareId()); + } String providedAuthCodeHash = passwordEncoder.encode(userAuth.getAuthCode()); String actualAuthCodeHash = flingEntity.getAuthCode(); @@ -90,15 +101,15 @@ public class AuthenticationService { } - public FlingToken parseAuthentication(String token) { + public FlingToken parseJwtAuthentication(String token) { Claims claims = jwtParser.parseClaimsJws(token).getBody(); switch (claims.getSubject()) { case "admin": - return new FlingToken(new FlingAdminAuthority(), token); + return new FlingToken(List.of(new FlingAdminAuthority()), token); case "user": UUID grantedFlingId = UUID.fromString(claims.get("id", String.class)); - return new FlingToken(new FlingUserAuthority(grantedFlingId), token); + return new FlingToken(List.of(new FlingUserAuthority(grantedFlingId)), token); default: throw new BadCredentialsException("Invalid token"); } @@ -116,4 +127,47 @@ public class AuthenticationService { .setExpiration(Date.from(Instant.now().plusSeconds(jwtExpiration))) .signWith(jwtSigningKey); } + + /** + * Creates a derived token with the given settings. Note that the returned string is opaque and + * should not not be interpreted in any way but only used as is. + * + * @param singleUse Whether this token should be deleted after a single use + * @return An opaque string representing the token + */ + @Transactional + public String deriveToken(Boolean singleUse) { + UUID id = UUID.randomUUID(); + TokenEntity tokenEntity = new TokenEntity(); + tokenEntity.setId(id); + if (singleUse != null) { + tokenEntity.setSingleUse(singleUse); + } + + SecurityContext securityContext = SecurityContextHolder.getContext(); + if (securityContext.getAuthentication() instanceof FlingToken) { + FlingToken flingToken = (FlingToken) securityContext.getAuthentication(); + tokenEntity.setToken(flingToken.getCredentials()); + } else { + // This should be prevented in FlingWebSecurityConfigurer + throw new IllegalStateException("Cannot derive token from current authentication"); + } + + tokenRepository.save(tokenEntity); + + return id.toString(); + } + + @Transactional + public FlingToken parseDerivedToken(String derivedToken) { + TokenEntity tokenEntity = tokenRepository.getOne(UUID.fromString(derivedToken)); + + FlingToken flingToken = parseJwtAuthentication(tokenEntity.getToken()); + + if (tokenEntity.getSingleUse()) { + tokenRepository.delete(tokenEntity); + } + + return flingToken; + } } diff --git a/service/fling/src/main/java/net/friedl/fling/service/AuthorizationService.java b/service/fling/src/main/java/net/friedl/fling/service/AuthorizationService.java index e136e31..1e58c2d 100644 --- a/service/fling/src/main/java/net/friedl/fling/service/AuthorizationService.java +++ b/service/fling/src/main/java/net/friedl/fling/service/AuthorizationService.java @@ -75,7 +75,9 @@ public class AuthorizationService { } FlingEntity flingEntity = flingRepository.findByShareId(shareId); - if(flingEntity == null) { throw new EntityNotFoundException("No entity for shareId="+shareId); } + if (flingEntity == null) { + throw new EntityNotFoundException("No entity for shareId=" + shareId); + } return allowFlingAccess(flingEntity.getId(), token); } diff --git a/service/fling/src/main/java/net/friedl/fling/service/FlingService.java b/service/fling/src/main/java/net/friedl/fling/service/FlingService.java index ecfb474..f2e2fae 100644 --- a/service/fling/src/main/java/net/friedl/fling/service/FlingService.java +++ b/service/fling/src/main/java/net/friedl/fling/service/FlingService.java @@ -90,7 +90,9 @@ public class FlingService { public FlingDto getByShareId(String shareId) { FlingEntity flingEntity = flingRepository.findByShareId(shareId); - if(flingEntity == null) { throw new EntityNotFoundException("No entity for shareId="+shareId); } + if (flingEntity == null) { + throw new EntityNotFoundException("No entity for shareId=" + shareId); + } return flingMapper.map(flingEntity); } diff --git a/service/fling/src/test/java/net/friedl/fling/controller/AuthenticationControllerTest.java b/service/fling/src/test/java/net/friedl/fling/controller/AuthenticationControllerTest.java index 5576e9e..8d5c79e 100644 --- a/service/fling/src/test/java/net/friedl/fling/controller/AuthenticationControllerTest.java +++ b/service/fling/src/test/java/net/friedl/fling/controller/AuthenticationControllerTest.java @@ -84,4 +84,5 @@ public class AuthenticationControllerTest { .andExpect(status().is(200)) .andExpect(content().string("token")); } + } diff --git a/service/fling/src/test/java/net/friedl/fling/service/AuthenticationServiceTest.java b/service/fling/src/test/java/net/friedl/fling/service/AuthenticationServiceTest.java index 658ef44..e829625 100644 --- a/service/fling/src/test/java/net/friedl/fling/service/AuthenticationServiceTest.java +++ b/service/fling/src/test/java/net/friedl/fling/service/AuthenticationServiceTest.java @@ -1,24 +1,33 @@ package net.friedl.fling.service; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyOrNullString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.security.Key; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import javax.persistence.EntityNotFoundException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; @@ -33,10 +42,13 @@ import io.jsonwebtoken.security.Keys; import net.friedl.fling.model.dto.AdminAuthDto; import net.friedl.fling.model.dto.UserAuthDto; import net.friedl.fling.persistence.entities.FlingEntity; +import net.friedl.fling.persistence.entities.TokenEntity; import net.friedl.fling.persistence.repositories.FlingRepository; -import net.friedl.fling.security.authentication.FlingAdminAuthority; +import net.friedl.fling.persistence.repositories.TokenRepository; +import net.friedl.fling.security.FlingAuthorities; import net.friedl.fling.security.authentication.FlingToken; -import net.friedl.fling.security.authentication.FlingUserAuthority; +import net.friedl.fling.security.authentication.authorities.FlingAdminAuthority; +import net.friedl.fling.security.authentication.authorities.FlingUserAuthority; @ExtendWith(SpringExtension.class) @TestPropertySource("classpath:/application-test.properties") @@ -48,6 +60,9 @@ public class AuthenticationServiceTest { @MockBean private FlingRepository flingRepository; + @MockBean + private TokenRepository tokenRepository; + @MockBean private JwtParser jwtParser; @@ -60,8 +75,11 @@ public class AuthenticationServiceTest { @Bean public AuthenticationService authenticationService(JwtParser jwtParser, - PasswordEncoder passwordEncoder, FlingRepository flingRepository) { - return new AuthenticationService(jwtParser, jwtSigningKey, passwordEncoder, flingRepository); + PasswordEncoder passwordEncoder, FlingRepository flingRepository, + TokenRepository tokenRepository) { + + return new AuthenticationService(jwtParser, jwtSigningKey, passwordEncoder, flingRepository, + tokenRepository); } } @@ -118,23 +136,24 @@ public class AuthenticationServiceTest { @Test public void authenticate_noFlingForShareId_throws() { - UserAuthDto userAuthDto = UserAuthDto.builder() + UserAuthDto userAuthDto = UserAuthDto.builder() .authCode("authCode") .shareId("doesNotExist").build(); when(flingRepository.findByShareId(any(String.class))).thenReturn(null); when(passwordEncoder.encode(any(String.class))).thenReturn("authCodeHash"); - assertThrows(EntityNotFoundException.class, () -> authenticationService.authenticate(userAuthDto)); + assertThrows(EntityNotFoundException.class, + () -> authenticationService.authenticate(userAuthDto)); } @Test - public void parseAuthentication_owner_AdminAuthority() { + public void parseJwtAuthentication_owner_AdminAuthority() { Jws jwsClaims = new DefaultJws<>(new DefaultJwsHeader(), new DefaultClaims(Map.of("sub", "admin")), "signature"); when(jwtParser.parseClaimsJws(any(String.class))).thenReturn(jwsClaims); - FlingToken flingToken = authenticationService.parseAuthentication("any"); + FlingToken flingToken = authenticationService.parseJwtAuthentication("any"); assertThat(flingToken.isAuthenticated(), equalTo(true)); // authorized for any fling assertThat(flingToken.authorizedForFling(UUID.randomUUID()), equalTo(true)); @@ -144,12 +163,12 @@ public class AuthenticationServiceTest { } @Test - public void parseAuthentication_user_UserAuthorityForId() { + public void parseJwtAuthentication_user_UserAuthorityForId() { Jws jwsClaims = new DefaultJws<>(new DefaultJwsHeader(), new DefaultClaims(Map.of("sub", "user", "id", new UUID(0, 0).toString())), "signature"); when(jwtParser.parseClaimsJws(any(String.class))).thenReturn(jwsClaims); - FlingToken flingToken = authenticationService.parseAuthentication("any"); + FlingToken flingToken = authenticationService.parseJwtAuthentication("any"); assertThat(flingToken.isAuthenticated(), equalTo(true)); // authorized for fling in token assertThat(flingToken.authorizedForFling(new UUID(0, 0)), equalTo(true)); @@ -161,12 +180,110 @@ public class AuthenticationServiceTest { } @Test - public void parseAuthentication_unknownSubject_throws() { + public void parseJwtAuthentication_unknownSubject_throws() { Jws jwsClaims = new DefaultJws<>(new DefaultJwsHeader(), new DefaultClaims(Map.of("sub", "unknownSubject")), "signature"); when(jwtParser.parseClaimsJws(any(String.class))).thenReturn(jwsClaims); assertThrows(BadCredentialsException.class, - () -> authenticationService.parseAuthentication("any")); + () -> authenticationService.parseJwtAuthentication("any")); + } + + @Test + public void deriveToken_noAuthenticationInSecurityContext_throws() { + assertThrows(IllegalStateException.class, + () -> authenticationService.deriveToken(false)); + } + + @Test + public void deriveToken_authenticationInSecurityContext_ok() { + FlingToken flingToken = new FlingToken(List.of(new FlingAdminAuthority()), "token"); + SecurityContextHolder.setContext(new SecurityContextImpl(flingToken)); + + String derivedToken = authenticationService.deriveToken(null); + + assertThat(derivedToken, is(not(emptyOrNullString()))); + SecurityContextHolder.clearContext(); + } + + @Test + public void deriveToken_singleUseNotSet_singleUseIsTrue() { + FlingToken flingToken = new FlingToken(List.of(new FlingAdminAuthority()), "token"); + SecurityContextHolder.setContext(new SecurityContextImpl(flingToken)); + + ArgumentCaptor tokenEntityCaptor = ArgumentCaptor.forClass(TokenEntity.class); + + authenticationService.deriveToken(null); + + verify(tokenRepository).save(tokenEntityCaptor.capture()); + assertThat(tokenEntityCaptor.getValue().getSingleUse(), is(true)); + } + + @Test + public void deriveToken_singleUseFalse_singleUseIsFalse() { + FlingToken flingToken = new FlingToken(List.of(new FlingAdminAuthority()), "token"); + SecurityContextHolder.setContext(new SecurityContextImpl(flingToken)); + + ArgumentCaptor tokenEntityCaptor = ArgumentCaptor.forClass(TokenEntity.class); + + authenticationService.deriveToken(false); + + verify(tokenRepository).save(tokenEntityCaptor.capture()); + assertThat(tokenEntityCaptor.getValue().getSingleUse(), is(false)); + } + + @Test + public void parseDerivedToken_singleUse_deletesToken() { + String token = UUID.randomUUID().toString(); + Jws jwsClaims = new DefaultJws<>(new DefaultJwsHeader(), + new DefaultClaims(Map.of("sub", "admin")), "signature"); + TokenEntity tokenEntity = new TokenEntity(); + tokenEntity.setId(UUID.fromString(token)); + tokenEntity.setSingleUse(true); + tokenEntity.setToken("jwtToken"); + + when(jwtParser.parseClaimsJws(any(String.class))).thenReturn(jwsClaims); + when(tokenRepository.getOne(any(UUID.class))).thenReturn(tokenEntity); + + authenticationService.parseDerivedToken(token); + + verify(tokenRepository).delete(tokenEntity); + } + + @Test + public void parseDerivedToken_singleUseFalse_doesNotDeleteToken() { + String token = UUID.randomUUID().toString(); + Jws jwsClaims = new DefaultJws<>(new DefaultJwsHeader(), + new DefaultClaims(Map.of("sub", "admin")), "signature"); + TokenEntity tokenEntity = new TokenEntity(); + tokenEntity.setId(UUID.fromString(token)); + tokenEntity.setSingleUse(false); + tokenEntity.setToken("jwtToken"); + + when(jwtParser.parseClaimsJws(any(String.class))).thenReturn(jwsClaims); + when(tokenRepository.getOne(any(UUID.class))).thenReturn(tokenEntity); + + authenticationService.parseDerivedToken(token); + + verify(tokenRepository, never()).delete(tokenEntity); + } + + @Test + public void parseDerivedToken_returnsParentAuthentication() { + String token = UUID.randomUUID().toString(); + Jws jwsClaims = new DefaultJws<>(new DefaultJwsHeader(), + new DefaultClaims(Map.of("sub", "admin")), "signature"); + TokenEntity tokenEntity = new TokenEntity(); + tokenEntity.setId(UUID.fromString(token)); + tokenEntity.setSingleUse(true); + tokenEntity.setToken("jwtToken"); + + when(jwtParser.parseClaimsJws(any(String.class))).thenReturn(jwsClaims); + when(tokenRepository.getOne(any(UUID.class))).thenReturn(tokenEntity); + + FlingToken flingToken = authenticationService.parseDerivedToken(token); + + assertEquals(flingToken.getAuthorities().stream().findFirst().get().getAuthority(), + FlingAuthorities.FLING_ADMIN.getAuthority()); } } diff --git a/service/fling/src/test/java/net/friedl/fling/service/AuthorizationServiceTest.java b/service/fling/src/test/java/net/friedl/fling/service/AuthorizationServiceTest.java index 3ad1820..547a2aa 100644 --- a/service/fling/src/test/java/net/friedl/fling/service/AuthorizationServiceTest.java +++ b/service/fling/src/test/java/net/friedl/fling/service/AuthorizationServiceTest.java @@ -19,9 +19,9 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.junit.jupiter.SpringExtension; import net.friedl.fling.persistence.entities.FlingEntity; import net.friedl.fling.persistence.repositories.FlingRepository; -import net.friedl.fling.security.authentication.FlingAdminAuthority; import net.friedl.fling.security.authentication.FlingToken; -import net.friedl.fling.security.authentication.FlingUserAuthority; +import net.friedl.fling.security.authentication.authorities.FlingAdminAuthority; +import net.friedl.fling.security.authentication.authorities.FlingUserAuthority; @ExtendWith(SpringExtension.class) public class AuthorizationServiceTest { @@ -50,7 +50,7 @@ public class AuthorizationServiceTest { @Test public void allowUpload_flingAdmin_true() { - FlingToken flingToken = new FlingToken(new FlingAdminAuthority(), "jwtToken"); + FlingToken flingToken = new FlingToken(List.of(new FlingAdminAuthority()), "jwtToken"); assertTrue(authorizationService.allowUpload(UUID.randomUUID(), flingToken)); } @@ -59,7 +59,8 @@ public class AuthorizationServiceTest { FlingEntity flingEntity = new FlingEntity(); flingEntity.setAllowUpload(false); - FlingToken flingToken = new FlingToken(new FlingUserAuthority(new UUID(0, 0)), "jwtToken"); + FlingToken flingToken = + new FlingToken(List.of(new FlingUserAuthority(new UUID(0, 0))), "jwtToken"); when(flingRepository.getOne(new UUID(0, 0))).thenReturn(flingEntity); @@ -71,7 +72,8 @@ public class AuthorizationServiceTest { FlingEntity flingEntity = new FlingEntity(); flingEntity.setAllowUpload(true); - FlingToken flingToken = new FlingToken(new FlingUserAuthority(new UUID(0, 0)), "jwtToken"); + FlingToken flingToken = + new FlingToken(List.of(new FlingUserAuthority(new UUID(0, 0))), "jwtToken"); when(flingRepository.getOne(new UUID(1, 1))).thenReturn(flingEntity); @@ -84,7 +86,8 @@ public class AuthorizationServiceTest { FlingEntity flingEntity = new FlingEntity(); flingEntity.setAllowUpload(true); - FlingToken flingToken = new FlingToken(new FlingUserAuthority(new UUID(0, 0)), "jwtToken"); + FlingToken flingToken = + new FlingToken(List.of(new FlingUserAuthority(new UUID(0, 0))), "jwtToken"); when(flingRepository.getOne(new UUID(0, 0))).thenReturn(flingEntity); @@ -101,25 +104,28 @@ public class AuthorizationServiceTest { @Test public void allowFlingAcess_flingAdmin_true() { - FlingToken flingToken = new FlingToken(new FlingAdminAuthority(), "jwtToken"); + FlingToken flingToken = new FlingToken(List.of(new FlingAdminAuthority()), "jwtToken"); assertTrue(authorizationService.allowFlingAccess(UUID.randomUUID(), flingToken)); } @Test public void allowFlingAcess_flingUser_notAuthorizedForId_false() { - FlingToken flingToken = new FlingToken(new FlingUserAuthority(new UUID(0, 0)), "jwtToken"); + FlingToken flingToken = + new FlingToken(List.of(new FlingUserAuthority(new UUID(0, 0))), "jwtToken"); assertFalse(authorizationService.allowFlingAccess(new UUID(1, 1), flingToken)); } @Test public void allowFlingAcess_flingUser_authorizedForId_true() { - FlingToken flingToken = new FlingToken(new FlingUserAuthority(new UUID(0, 0)), "jwtToken"); + FlingToken flingToken = + new FlingToken(List.of(new FlingUserAuthority(new UUID(0, 0))), "jwtToken"); assertTrue(authorizationService.allowFlingAccess(new UUID(0, 0), flingToken)); } @Test public void allowFlingAccessByShareId_noFlingForShareId_throw() { - FlingToken flingToken = new FlingToken(new FlingUserAuthority(new UUID(0, 0)), "jwtToken"); + FlingToken flingToken = + new FlingToken(List.of(new FlingUserAuthority(new UUID(0, 0))), "jwtToken"); when(flingRepository.findByShareId(any(String.class))).thenReturn(null); assertThrows(EntityNotFoundException.class, diff --git a/web/fling/src/components/admin/FlingArtifacts.jsx b/web/fling/src/components/admin/FlingArtifacts.jsx index 52de4cd..8ceb7d3 100644 --- a/web/fling/src/components/admin/FlingArtifacts.jsx +++ b/web/fling/src/components/admin/FlingArtifacts.jsx @@ -1,114 +1,114 @@ import log from 'loglevel'; -import React, {useState, useEffect, useRef} from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; -import {artifactClient} from '../../util/flingclient'; +import { ArtifactClient, FlingClient } from '../../util/fc'; +import { prettifyTimestamp } from '../../util/fn'; function FlingArtifactControl(props) { - let iframeContainer = useRef(null); + let iframeContainer = useRef(null); + const artifactClient = new ArtifactClient(); - function handleDelete(ev) { - artifactClient.deleteArtifact(props.artifact.id) - .then(() => props.reloadArtifactsFn()); - } + function handleDelete(ev) { + artifactClient.deleteArtifact(props.artifact.id) + .then(() => props.reloadArtifactsFn()); + } - function handleDownload(ev) { - artifactClient.downloadArtifact(props.artifact.id) - .then(url => { - // We need this iframe hack because with a regular href, while - // the browser downloads the file fine, it also reloads the page, hence - // loosing all logs and state - let frame = document.createElement("iframe"); - frame.src = url; - iframeContainer.current.appendChild(frame); - }); - } + function handleDownload(ev) { + artifactClient.downloadArtifactWithHttpInfo(props.artifact.id) + .then(response => { + log.info(response.headers); + var blob = new Blob([response.data], {type: response.type}); + if(window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveBlob(blob, response.name); + } + else{ + var elem = window.document.createElement('a'); + elem.href = window.URL.createObjectURL(blob); + elem.download = response.name; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + } + }); + } - return( -
- - - -
-
- ); + return ( +
+ + + +
+
+ ); } function FlingArtifactRow(props) { - let [hovered, setHovered] = useState(false); - function readableBytes(bytes) { - if(bytes <= 0) return "0 KB"; + let [hovered, setHovered] = useState(false); - var i = Math.floor(Math.log(bytes) / Math.log(1024)), - sizes = ['Byte', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - - return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + sizes[i]; - } - - function localizedUploadDate() { - let d = new Date(props.artifact.uploadTime); - return d.toLocaleDateString(); - } - - return( - setHovered(true)} onMouseOut={() => setHovered(false)}> - {props.artifact.name} - {localizedUploadDate()} - {readableBytes(props.artifact.size)} -