0.1 #1
20 changed files with 483 additions and 189 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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<Boolean> singleUse)
|
||||
{
|
||||
return authenticationService.deriveToken(singleUse.orElse(true));
|
||||
}
|
||||
//@formatter:on
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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<TokenEntity, UUID> {
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<String> 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()
|
||||
|
|
|
@ -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<GrantedAuthority> 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 FlingUserAuthority)) continue;
|
||||
if (grantedAuthority instanceof FlingAdminAuthority) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (grantedAuthority instanceof FlingUserAuthority) {
|
||||
UUID grantedFlingId = ((FlingUserAuthority) grantedAuthority).getFlingId();
|
||||
if (grantedFlingId.equals(id)) return true;
|
||||
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
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -49,7 +50,7 @@ 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);
|
||||
FlingToken token = authenticationService.parseJwtAuthentication(authToken);
|
||||
log.info("Authenticated as {}", token.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")));
|
||||
securityContext.setAuthentication(token);
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String> authenticate(AdminAuthDto adminAuth) {
|
||||
|
@ -71,7 +80,9 @@ public class AuthenticationService {
|
|||
public Optional<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -84,4 +84,5 @@ public class AuthenticationControllerTest {
|
|||
.andExpect(status().is(200))
|
||||
.andExpect(content().string("token"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -125,16 +143,17 @@ public class AuthenticationServiceTest {
|
|||
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<Claims> 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<Claims> 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<Claims> 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<TokenEntity> 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<TokenEntity> 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<Claims> 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<Claims> 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<Claims> 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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import log from 'loglevel';
|
||||
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);
|
||||
const artifactClient = new ArtifactClient();
|
||||
|
||||
function handleDelete(ev) {
|
||||
artifactClient.deleteArtifact(props.artifact.id)
|
||||
|
@ -13,14 +16,21 @@ function FlingArtifactControl(props) {
|
|||
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -36,25 +46,12 @@ function FlingArtifactControl(props) {
|
|||
|
||||
function FlingArtifactRow(props) {
|
||||
let [hovered, setHovered] = useState(false);
|
||||
function readableBytes(bytes) {
|
||||
if(bytes <= 0) return "0 KB";
|
||||
|
||||
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 (
|
||||
<tr key={props.artifact.id} className="artifact-row" onMouseOver={() => setHovered(true)} onMouseOut={() => setHovered(false)}>
|
||||
<td>{props.artifact.name}</td>
|
||||
<td>{localizedUploadDate()}</td>
|
||||
<td>{readableBytes(props.artifact.size)}</td>
|
||||
<td>{props.artifact.path}</td>
|
||||
<td>{prettifyTimestamp(props.artifact.creationTime, true)}</td>
|
||||
<td></td>
|
||||
<td><FlingArtifactControl artifact={props.artifact} reloadArtifactsFn={props.reloadArtifactsFn} hidden={!hovered} /></td>
|
||||
</tr>
|
||||
);
|
||||
|
@ -68,9 +65,12 @@ function FlingInfo(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function FlingArtifacts(props) {
|
||||
export default function FlingArtifacts() {
|
||||
const flingClient = new FlingClient();
|
||||
const activeFling = useSelector(state => state.flings.activeFling);
|
||||
const [artifacts, setArtifacts] = useState([]);
|
||||
useEffect(getArtifacts, [props.activeFling]);
|
||||
|
||||
useEffect(getArtifacts, [activeFling]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -93,15 +93,15 @@ export default function FlingArtifacts(props) {
|
|||
);
|
||||
|
||||
function getArtifacts() {
|
||||
if (!props.activeFling) {
|
||||
if (!activeFling) {
|
||||
log.debug("No fling active. Not getting artifacts.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Fling ${props.activeFling} active. Getting artifacts.`);
|
||||
log.debug(`Fling ${activeFling} active. Getting artifacts.`);
|
||||
let artifacts = [];
|
||||
|
||||
artifactClient.getArtifacts(props.activeFling)
|
||||
flingClient.getArtifacts(activeFling.id)
|
||||
.then(result => {
|
||||
log.debug(`Got ${result.length} artifacts`);
|
||||
for (let artifact of result) {
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
import log from "loglevel";
|
||||
import produce from "immer";
|
||||
|
||||
import { SET_FLINGS, SET_ACTIVE_FLING, ADD_FLING } from "../actionTypes";
|
||||
|
||||
const initialState = {
|
||||
// type [fc.Artifact]
|
||||
aritfacts: []
|
||||
}
|
||||
|
||||
export default produce((draft, action) => {
|
||||
switch (action.type) {
|
||||
case SET_FLINGS:
|
||||
draft.flings = action.payload;
|
||||
break;
|
||||
case ADD_FLING:
|
||||
// Check storage again here, otherwise there could be a race
|
||||
// condition due to async calls of SET_FLINGS and ADD_FLING
|
||||
let foundFlingIdx = draft.flings.findIndex(fling =>
|
||||
fling.id === action.payload.id);
|
||||
|
||||
if (foundFlingIdx === -1) {
|
||||
log.debug(`Adding new fling with id ${action.payload.id}`)
|
||||
draft.flings.push(action.payload);
|
||||
} else {
|
||||
log.debug(`Fling already exists. ` +
|
||||
`Updating fling with id ${action.payload.id}`)
|
||||
draft.flings[foundFlingIdx] = action.payload
|
||||
}
|
||||
break;
|
||||
case SET_ACTIVE_FLING:
|
||||
draft.activeFling = action.payload;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return draft;
|
||||
|
||||
}, initialState);
|
Loading…
Reference in a new issue