diff --git a/README.md b/README.md index bac3bf1..b7ae852 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,11 @@ Business metrics: | link_visit_total | Visit to the all shortlinks | result_status (status of visits) | | handled_error_total | REST API errors | code (code in result block) | | qr_generated | Count of generated QR codes | - | +// TODO Unacth request + + +Security +------- Configuration @@ -152,6 +157,7 @@ Configuration | Custom.QR | QR_HEIGHT | QR image height | positive int | 200 | | Custom.QR | QR_WIDTH | QR image width | positive int | 200 | | Custom.QR | QR_ENDPOINT | Full endpoint of shortlink, must have %s in place when shortlink will be placed | string | http://localhost:80/s/%s | +// TODO Security params @@ -206,3 +212,10 @@ app initializing and starts API interaction of some use-case of app: create the The main reason of it to awoid to cover the all app of units and test the main processed of usage in almost real enviroment: DB in container, REST API interaction step-by-step + + +### BaseProtoFramework + +There is a simple base framework for build Service and Model, +see `BaseService`, `BaseCRUDService` for details for service and `Model` and `Activating` for entity. +Provides some basic CRUD operation for Entity diff --git a/TODO.md b/TODO.md index 5dc1a10..6fbf19e 100644 --- a/TODO.md +++ b/TODO.md @@ -55,14 +55,20 @@ Phase 4 - Features part 1 * ~~QR code generator for links~~ +* ~~Separate Swagger group API for private and public~~ + + +Phase 5 - Features part 2 +------------------------- + * Security - Spring security with authorized API requests - Add test for all above -* ~~Separate Swagger group API for private and public~~ +* Password auth for links visit (custom popup or browser default pop) -Phase 5 - Features part 2 +Phase 6 - Features part 3 ------------------------- * Custom links short URL @@ -70,12 +76,10 @@ Phase 5 - Features part 2 - Generator resolver - Stop list of some links -* Password auth for links visit (custom popup or browser default pop) - * Saves not found links in visits table -Phase 6 - Features part 3 +Phase 7 - Features part 4 ------------------------- * SonarKube check outside of IDEA (SonarLint) in build CI with quality gate diff --git a/pom.xml b/pom.xml index 50ad561..bd88eec 100644 --- a/pom.xml +++ b/pom.xml @@ -85,11 +85,22 @@ flyway-core + + org.springframework.boot + spring-boot-starter-security + + org.springdoc springdoc-openapi-ui ${spring-docs.version} + + org.springdoc + springdoc-openapi-security + ${spring-docs.version} + + org.springframework.cloud spring-cloud-starter-sleuth diff --git a/src/main/java/dev/alnat/tinylinkshortener/config/Constants.java b/src/main/java/dev/alnat/tinylinkshortener/config/Constants.java new file mode 100644 index 0000000..c61556e --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/config/Constants.java @@ -0,0 +1,15 @@ +package dev.alnat.tinylinkshortener.config; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Created by @author AlNat on 27.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class Constants { + + public static final String AUTH_HEADER_NAME = "X-USER-KEY"; + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/config/OpenAPIConfiguration.java b/src/main/java/dev/alnat/tinylinkshortener/config/OpenAPIConfiguration.java index e66ea63..37059cb 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/config/OpenAPIConfiguration.java +++ b/src/main/java/dev/alnat/tinylinkshortener/config/OpenAPIConfiguration.java @@ -1,12 +1,16 @@ package dev.alnat.tinylinkshortener.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.media.DateTimeSchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springdoc.core.GroupedOpenApi; import org.springdoc.core.customizers.OpenApiCustomiser; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -17,6 +21,7 @@ * Licensed by Apache License, Version 2.0 */ @Configuration +@ConditionalOnProperty(name = "springdoc.api-docs.enabled", havingValue = "true") public class OpenAPIConfiguration { @Bean @@ -65,7 +70,18 @@ public GroupedOpenApi privateOpenApi() { .group("private") .displayName("private_api") .pathsToMatch("/api/**") + .addOpenApiCustomiser(securedApiCustomizer()) .build(); } + public OpenApiCustomiser securedApiCustomizer() { + return openApi -> openApi + .addSecurityItem(new SecurityRequirement().addList("apiKey")) + .components(new Components() + .addSecuritySchemes("apiKey", new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name(Constants.AUTH_HEADER_NAME))); + } + } diff --git a/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java b/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java new file mode 100644 index 0000000..9b24c1c --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java @@ -0,0 +1,69 @@ +package dev.alnat.tinylinkshortener.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.alnat.tinylinkshortener.mapper.UserMapper; +import dev.alnat.tinylinkshortener.security.HeaderKeySecurityFilter; +import dev.alnat.tinylinkshortener.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.savedrequest.NullRequestCache; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) +@RequiredArgsConstructor +public class SecurityConfig { + + private static final RequestMatcher NOT_AUTH_ENDPOINTS = new OrRequestMatcher( + new AntPathRequestMatcher("/s/**"), // redirects + new AntPathRequestMatcher("/**/swagger-resources/**"), + new AntPathRequestMatcher("/**/swagger-ui.html/**"), + new AntPathRequestMatcher("/**/swagger-ui/**"), + new AntPathRequestMatcher("/**/swagger-ui.html"), + new AntPathRequestMatcher("/favicon.ico"), + new AntPathRequestMatcher("/webjars/**"), + new AntPathRequestMatcher("/v3/api-docs/**"), + new AntPathRequestMatcher("/v3/api-docs"), + new AntPathRequestMatcher("/actuator/**") + ); + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public HeaderKeySecurityFilter securityFilter(final UserService service, + final PasswordEncoder encoder, + final ObjectMapper mapper, + final UserMapper userMapper) { + return new HeaderKeySecurityFilter(service, NOT_AUTH_ENDPOINTS, encoder, mapper, userMapper); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, HeaderKeySecurityFilter filter) throws Exception { + return http + .addFilterBefore(filter, BasicAuthenticationFilter.class) + .requestCache().requestCache(new NullRequestCache()) // Disable caching + .and() + .csrf().disable() + .authorizeRequests().requestMatchers(NOT_AUTH_ENDPOINTS).permitAll() + .anyRequest().authenticated() + .and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .build(); + } +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/controller/LinkController.java b/src/main/java/dev/alnat/tinylinkshortener/controller/LinkController.java index eb446a3..664aa6d 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/controller/LinkController.java +++ b/src/main/java/dev/alnat/tinylinkshortener/controller/LinkController.java @@ -42,9 +42,11 @@ public class LinkController { public Result create( @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "DTO for new short link request", required = true) @RequestBody @Valid final LinkInDTO dto) { + // TODO CheckAnonymous return linkService.create(dto); } + // TODO Secured User, Admin @Operation(summary = "Search shortlink by ID") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request completed, see result in code field in response") @@ -55,6 +57,7 @@ public Result find(@Parameter(in = ParameterIn.PATH, description = " return linkService.find(id); } + // TODO Secured User, Admin @Operation(summary = "Search shortlink by shortlink (as lookup method)") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request completed, see result in code field in response") @@ -65,6 +68,7 @@ public Result find(@Parameter(in = ParameterIn.QUERY, description = return linkService.find(shortLink); } + // TODO Secured User, Admin @Operation(summary = "Deactivate shortlink by ID") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request completed, see result in code field in response") @@ -75,6 +79,7 @@ public Result deactivate(@Parameter(in = ParameterIn.PATH, description = " return linkService.deactivate(id); } + // TODO Secured User, Admin @Operation(summary = "Deactivate shortlink by shortlink") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request completed, see result in code field in response") diff --git a/src/main/java/dev/alnat/tinylinkshortener/controller/ShortLinkController.java b/src/main/java/dev/alnat/tinylinkshortener/controller/ShortLinkController.java index c40b7ff..baa6e2a 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/controller/ShortLinkController.java +++ b/src/main/java/dev/alnat/tinylinkshortener/controller/ShortLinkController.java @@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; import org.springframework.util.StreamUtils; import org.springframework.web.bind.annotation.*; @@ -32,7 +33,7 @@ * Licensed by Apache License, Version 2.0 */ @Slf4j -@RestController +@Controller @Setter @RequestMapping(value = "/s/", produces = MediaType.APPLICATION_JSON_VALUE) @Tag(name = "Controller for requesting shortlinks", description = "Front-end controller for users") diff --git a/src/main/java/dev/alnat/tinylinkshortener/controller/UserController.java b/src/main/java/dev/alnat/tinylinkshortener/controller/UserController.java new file mode 100644 index 0000000..794e4d9 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/controller/UserController.java @@ -0,0 +1,11 @@ +package dev.alnat.tinylinkshortener.controller; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public class UserController { + + // TODO CRUD (only by admin) + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/controller/VisitController.java b/src/main/java/dev/alnat/tinylinkshortener/controller/VisitController.java index 821a89b..1d3630a 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/controller/VisitController.java +++ b/src/main/java/dev/alnat/tinylinkshortener/controller/VisitController.java @@ -50,6 +50,7 @@ public class VisitController { @Value("${custom.paging.default-timeout}") private Duration pagingTimeout; + // TODO RoleUser, RoleAdmin @Operation(summary = "Paging visit results for short link") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request completed, see result in code field in response") @@ -75,6 +76,7 @@ public DeferredResult searchRawStatistics( return deferredResult; } + // TODO RoleUser @Operation(summary = "Aggregating visit results for short link") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request completed, see result in code field in response") diff --git a/src/main/java/dev/alnat/tinylinkshortener/controller/handler/GlobalExceptionHandler.java b/src/main/java/dev/alnat/tinylinkshortener/controller/handler/GlobalExceptionHandler.java index 447b576..aa6bec0 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/controller/handler/GlobalExceptionHandler.java +++ b/src/main/java/dev/alnat/tinylinkshortener/controller/handler/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.lang.Nullable; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.FieldError; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -122,6 +123,15 @@ protected ResponseEntity handleExceptionInternal(Exception ex, .body(ResultFactory.error("Internal Server Error, traceId=[" + traceId + "]")); } + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException ex) { + log.warn("Security exception", ex); + return new ResponseEntity<>( + ResultFactory.unauthorized(), + HttpStatus.OK + ); + } + /** * Global exception */ diff --git a/src/main/java/dev/alnat/tinylinkshortener/dto/UserInDTO.java b/src/main/java/dev/alnat/tinylinkshortener/dto/UserInDTO.java new file mode 100644 index 0000000..ae0a004 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/dto/UserInDTO.java @@ -0,0 +1,17 @@ +package dev.alnat.tinylinkshortener.dto; + +import dev.alnat.tinylinkshortener.model.enums.UserRole; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public class UserInDTO { + + private String name; + + private UserRole role; + + private String key; + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/dto/UserOutDTO.java b/src/main/java/dev/alnat/tinylinkshortener/dto/UserOutDTO.java new file mode 100644 index 0000000..9f628d3 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/dto/UserOutDTO.java @@ -0,0 +1,33 @@ +package dev.alnat.tinylinkshortener.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import dev.alnat.tinylinkshortener.model.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Data +@ToString +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Information about the user") +public class UserOutDTO { + + // TODO Swagger + + private Integer id; + + private String name; + + private UserRole role; + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/dto/common/ResultFactory.java b/src/main/java/dev/alnat/tinylinkshortener/dto/common/ResultFactory.java index cbe223e..a28bb1f 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/dto/common/ResultFactory.java +++ b/src/main/java/dev/alnat/tinylinkshortener/dto/common/ResultFactory.java @@ -11,7 +11,6 @@ * Created by @author AlNat on 13.01.2023. * Licensed by Apache License, Version 2.0 */ -@SuppressWarnings("rawtypes") @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ResultFactory { @@ -35,6 +34,14 @@ public static Result badRequest(final Map> errors) { return Result.badRequest(HttpStatus.BAD_REQUEST.value(), errors); } + public static Result unauthorized() { + return Result.error(HttpStatus.UNAUTHORIZED.value(), "Not authorized"); + } + + public static Result insufficientRights() { + return Result.error(HttpStatus.FORBIDDEN.value(), "Insufficient rights for operation"); + } + public static Result notFound() { return Result.error(HttpStatus.NOT_FOUND.value()); } diff --git a/src/main/java/dev/alnat/tinylinkshortener/exceptions/NotFoundException.java b/src/main/java/dev/alnat/tinylinkshortener/exceptions/NotFoundException.java new file mode 100644 index 0000000..7abe64e --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/exceptions/NotFoundException.java @@ -0,0 +1,21 @@ +package dev.alnat.tinylinkshortener.exceptions; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public class NotFoundException extends Exception { + + public NotFoundException(String message) { + super(message); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } + + public NotFoundException(Throwable cause) { + super(cause); + } + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/mapper/UserMapper.java b/src/main/java/dev/alnat/tinylinkshortener/mapper/UserMapper.java new file mode 100644 index 0000000..c91ad81 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/mapper/UserMapper.java @@ -0,0 +1,14 @@ +package dev.alnat.tinylinkshortener.mapper; + +import dev.alnat.tinylinkshortener.dto.UserOutDTO; +import dev.alnat.tinylinkshortener.mapper.common.EntityMapper; +import dev.alnat.tinylinkshortener.model.User; +import org.mapstruct.Mapper; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Mapper(componentModel = "spring") +public interface UserMapper extends EntityMapper { +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/Activating.java b/src/main/java/dev/alnat/tinylinkshortener/model/Activating.java new file mode 100644 index 0000000..e08af57 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/model/Activating.java @@ -0,0 +1,15 @@ +package dev.alnat.tinylinkshortener.model; + +/** + * Marker for Entity that's cant be deleted and must be updated with active = false + * + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public interface Activating { + + void setActive(boolean value); + + boolean isActive(); + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/Link.java b/src/main/java/dev/alnat/tinylinkshortener/model/Link.java index 5382c2f..59d10fc 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/model/Link.java +++ b/src/main/java/dev/alnat/tinylinkshortener/model/Link.java @@ -25,7 +25,7 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) @Table(name = "link") -public class Link { +public class Link implements Model { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -68,6 +68,11 @@ public class Link { @Fetch(FetchMode.SELECT) private List visitList; + @ToString.Exclude + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false, referencedColumnName = "id") + private User user; + public Integer getCurrentVisitCount() { if (currentVisitCount == null) { diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/Model.java b/src/main/java/dev/alnat/tinylinkshortener/model/Model.java new file mode 100644 index 0000000..2df8454 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/model/Model.java @@ -0,0 +1,14 @@ +package dev.alnat.tinylinkshortener.model; + +/** + * Marker for entity class + * + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public interface Model { + + ID getId(); + void setId(ID id); + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/User.java b/src/main/java/dev/alnat/tinylinkshortener/model/User.java new file mode 100644 index 0000000..5e9c88b --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/model/User.java @@ -0,0 +1,94 @@ +package dev.alnat.tinylinkshortener.model; + +import dev.alnat.tinylinkshortener.model.enums.UserRole; +import lombok.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import javax.persistence.*; +import java.util.Collection; +import java.util.Collections; + +/** + * Created by @author AlNat on 27.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +@Table(name = "user") +public class User implements Model, Activating, UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + @ToString.Include + private Integer id; + + @ToString.Include + private String name; + + /** + * Encrypted API key + */ + private String key; + + + @ToString.Include + private UserRole role; + + @ToString.Include + private Boolean active; + + @Override + public void setActive(boolean value) { + this.active = value; + } + + /** + * If for some reasons active field in DB not filled decide that's this user is not active + */ + @Override + public boolean isActive() { + return Boolean.TRUE.equals(active); + } + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority(role.getRole())); + } + + @Override + public String getPassword() { + return key; + } + + @Override + public String getUsername() { + return name; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return !isActive(); + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return isActive(); + } +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/Visit.java b/src/main/java/dev/alnat/tinylinkshortener/model/Visit.java index bb96f9f..86358ae 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/model/Visit.java +++ b/src/main/java/dev/alnat/tinylinkshortener/model/Visit.java @@ -27,7 +27,7 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) @Table(name = "visit") -public class Visit implements Serializable, Comparable { +public class Visit implements Serializable, Comparable, Model { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/converter/UserRoleConverter.java b/src/main/java/dev/alnat/tinylinkshortener/model/converter/UserRoleConverter.java new file mode 100644 index 0000000..3e70f82 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/model/converter/UserRoleConverter.java @@ -0,0 +1,26 @@ +package dev.alnat.tinylinkshortener.model.converter; + +import dev.alnat.tinylinkshortener.model.enums.UserRole; + +import javax.persistence.AttributeConverter; +import javax.persistence.Converter; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Converter +public class UserRoleConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(UserRole visitStatus) { + return visitStatus == null ? null : visitStatus.getValue(); + } + + @Override + public UserRole convertToEntityAttribute(Integer status) { + return status == null ? null : UserRole.ofValue(status); + } + + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/model/enums/UserRole.java b/src/main/java/dev/alnat/tinylinkshortener/model/enums/UserRole.java new file mode 100644 index 0000000..d30aae7 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/model/enums/UserRole.java @@ -0,0 +1,29 @@ +package dev.alnat.tinylinkshortener.model.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Getter +@RequiredArgsConstructor +public enum UserRole { + + USER(0, "ROLE_USER"), + ADMIN(1, "ROLE_ADMIN"); + + private final int value; + private final String role; + + public static UserRole ofValue(int value){ + return Arrays.stream(UserRole.values()) + .filter(s -> s.getValue() == value) + .findFirst() + .orElseThrow(() -> new RuntimeException("Unknown role code!")); + } + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/repository/UserRepository.java b/src/main/java/dev/alnat/tinylinkshortener/repository/UserRepository.java new file mode 100644 index 0000000..0e39332 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/repository/UserRepository.java @@ -0,0 +1,17 @@ +package dev.alnat.tinylinkshortener.repository; + +import dev.alnat.tinylinkshortener.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public interface UserRepository extends JpaRepository { + + Optional findUserByName(String name); + Optional findByKey(String key); + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java b/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java new file mode 100644 index 0000000..2fd2123 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java @@ -0,0 +1,77 @@ +package dev.alnat.tinylinkshortener.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.alnat.tinylinkshortener.dto.common.ResultFactory; +import dev.alnat.tinylinkshortener.mapper.UserMapper; +import dev.alnat.tinylinkshortener.security.model.UserAuth; +import dev.alnat.tinylinkshortener.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static dev.alnat.tinylinkshortener.config.Constants.AUTH_HEADER_NAME; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Slf4j +@RequiredArgsConstructor +public class HeaderKeySecurityFilter extends OncePerRequestFilter { + + private final UserService userService; + private final RequestMatcher allowedEndpoints; + private final PasswordEncoder encoder; + private final ObjectMapper mapper; + private final UserMapper userMapper; + + + @Override + protected void doFilterInternal(HttpServletRequest servletRequest, + HttpServletResponse servletResponse, + FilterChain filterChain) + throws ServletException, IOException { + if (allowedEndpoints.matches(servletRequest)) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + + final String key = servletRequest.getHeader(AUTH_HEADER_NAME); + if (!StringUtils.hasText(key)){ + log.debug("Not passed header {} to request", AUTH_HEADER_NAME); + constructResponse(servletResponse, mapper.writeValueAsString(ResultFactory.unauthorized())); + return; + } + + var user = userService.findByKey(encoder.encode(key)); + if (user.isEmpty()) { + log.warn("Unauthorized request with [{}]:[{}]", AUTH_HEADER_NAME, key); + constructResponse(servletResponse, mapper.writeValueAsString(ResultFactory.unauthorized())); + return; + } + + SecurityContextHolder.getContext().setAuthentication(new UserAuth(key, userMapper.entityToDTO(user.get()))); + filterChain.doFilter(servletRequest, servletResponse); + } + + private static void constructResponse(ServletResponse response, + String body) throws IOException { + ((HttpServletResponse)response).setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().println(body); + } + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/security/model/UserAuth.java b/src/main/java/dev/alnat/tinylinkshortener/security/model/UserAuth.java new file mode 100644 index 0000000..5c0019a --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/security/model/UserAuth.java @@ -0,0 +1,42 @@ +package dev.alnat.tinylinkshortener.security.model; + +import dev.alnat.tinylinkshortener.dto.UserOutDTO; +import lombok.*; +import org.springframework.security.authentication.AbstractAuthenticationToken; + +import java.io.Serializable; + +/** + * Security context holder + *

+ * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode +public class UserAuth extends AbstractAuthenticationToken implements Serializable { + + private String header; + private UserOutDTO userOutDTO; + + public UserAuth(String header, UserOutDTO userOutDTO) { + super(null); + this.header = header; + this.userOutDTO = userOutDTO; + setAuthenticated(true); + } + + + @Override + public String getCredentials() { + return header; + } + + @Override + public UserOutDTO getPrincipal() { + return userOutDTO; + } + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/BaseService.java b/src/main/java/dev/alnat/tinylinkshortener/service/BaseService.java index 446a895..476b42b 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/BaseService.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/BaseService.java @@ -1,11 +1,38 @@ package dev.alnat.tinylinkshortener.service; +import dev.alnat.tinylinkshortener.exceptions.NotFoundException; +import dev.alnat.tinylinkshortener.model.Model; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + /** + * Basic CRUD operations for Entity + *

* Created by @author AlNat on 14.01.2023. * Licensed by Apache License, Version 2.0 */ -public interface BaseService { +@SuppressWarnings({"UnusedReturnValue", "unused"}) +public interface BaseService, ID> { + + E create(E entity); + + E update(E entity, ID id) throws NotFoundException; + + default E update(E entity) throws NotFoundException { + if (entity.getId() != null) { + update(entity, entity.getId()); + } + throw new IllegalArgumentException("Cant update entity with null ID!"); + } + + E getByID(ID id) throws NotFoundException; + + Optional findByID(ID id); + + List findPaginated(Pageable pageable); - // TODO CRUD OPERATIONS + void delete(ID id) throws NotFoundException; } diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/SecurityContextRetriever.java b/src/main/java/dev/alnat/tinylinkshortener/service/SecurityContextRetriever.java new file mode 100644 index 0000000..11d566c --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/service/SecurityContextRetriever.java @@ -0,0 +1,26 @@ +package dev.alnat.tinylinkshortener.service; + +import dev.alnat.tinylinkshortener.dto.UserOutDTO; +import dev.alnat.tinylinkshortener.model.enums.UserRole; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +/** + * Created by @author AlNat on 30.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public interface SecurityContextRetriever { + + default Optional getAuthUser() { + var principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + UserOutDTO dto = (UserOutDTO) principal; + return Optional.of(dto); + } + + default boolean hasRight(UserRole role) { + var user = getAuthUser(); + return user.isPresent() && user.get().getRole().equals(role); + } + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/UserService.java b/src/main/java/dev/alnat/tinylinkshortener/service/UserService.java new file mode 100644 index 0000000..bf17c5b --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/service/UserService.java @@ -0,0 +1,16 @@ +package dev.alnat.tinylinkshortener.service; + +import dev.alnat.tinylinkshortener.model.User; +import org.springframework.security.core.userdetails.UserDetailsService; + +import java.util.Optional; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +public interface UserService extends UserDetailsService, BaseService { + + Optional findByKey(String key); + +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/VisitService.java b/src/main/java/dev/alnat/tinylinkshortener/service/VisitService.java index 73b5f75..61d391f 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/VisitService.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/VisitService.java @@ -7,6 +7,7 @@ import dev.alnat.tinylinkshortener.dto.common.PaginalResult; import dev.alnat.tinylinkshortener.dto.common.Result; import dev.alnat.tinylinkshortener.model.Link; +import dev.alnat.tinylinkshortener.model.Visit; import dev.alnat.tinylinkshortener.model.enums.VisitStatus; import org.springframework.http.HttpHeaders; import org.springframework.web.context.request.async.DeferredResult; @@ -19,7 +20,7 @@ * Licensed by Apache License, Version 2.0 */ @SuppressWarnings("unused") -public interface VisitService { +public interface VisitService extends BaseService { Long saveNewVisit(final Link link, final VisitStatus status, final String ip, final String userAgent, final HttpHeaders headers); diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java b/src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java new file mode 100644 index 0000000..2d678c1 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java @@ -0,0 +1,89 @@ +package dev.alnat.tinylinkshortener.service.impl; + +import dev.alnat.tinylinkshortener.exceptions.NotFoundException; +import dev.alnat.tinylinkshortener.model.Activating; +import dev.alnat.tinylinkshortener.model.Model; +import dev.alnat.tinylinkshortener.service.BaseService; +import dev.alnat.tinylinkshortener.service.SecurityContextRetriever; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Base service to avoid boilerplate code for basic operation to entity + * ProtoFramework, if its possible :) + *

+ * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@RequiredArgsConstructor +@Transactional +public abstract class BaseCRUDService, ID> implements BaseService, SecurityContextRetriever { + + protected final JpaRepository repository; + + @Override + public E create(E entity) { + return repository.save(entity); + } + + @Override + public E update(E entity, ID id) throws NotFoundException { + getByID(id); // Check for existing + + if (entity.getId() == null || entity.getId() != id) { + entity.setId(id); + } + return repository.save(entity); + } + + + @Override + @Transactional(readOnly = true) + public E getByID(ID id) throws NotFoundException { + var entityOpt = findByID(id); + + if (entityOpt.isEmpty()) { + throw new NotFoundException("Not found entity with id " + id); + } + + return entityOpt.get(); + } + + @Override + @Transactional(readOnly = true) + public Optional findByID(ID id) { + return repository.findById(id); + } + + @Override + @Transactional(readOnly = true) + public List findPaginated(Pageable pageable) { + return repository.findAll(); + } + + @Override + public void delete(ID id) throws NotFoundException { + var entityOpt = findByID(id); + + if (entityOpt.isEmpty()) { + throw new NotFoundException("Not found entity with id " + id); + } + + var entity = entityOpt.get(); + + if (entity instanceof Activating) { + ((Activating) entity).setActive(Boolean.FALSE); + update(entity, id); + return; + } + + repository.deleteById(id); + } + +} + diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/impl/LinkServiceImpl.java b/src/main/java/dev/alnat/tinylinkshortener/service/impl/LinkServiceImpl.java index 11c4d42..66a27d6 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/impl/LinkServiceImpl.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/LinkServiceImpl.java @@ -8,11 +8,16 @@ import dev.alnat.tinylinkshortener.mapper.LinkMapper; import dev.alnat.tinylinkshortener.metric.MetricCollector; import dev.alnat.tinylinkshortener.metric.MetricsNames; +import dev.alnat.tinylinkshortener.model.Link; import dev.alnat.tinylinkshortener.model.enums.LinkStatus; +import dev.alnat.tinylinkshortener.model.enums.UserRole; import dev.alnat.tinylinkshortener.repository.LinkRepository; +import dev.alnat.tinylinkshortener.repository.UserRepository; import dev.alnat.tinylinkshortener.service.LinkService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,15 +27,26 @@ */ @Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -public class LinkServiceImpl implements LinkService { - - private final LinkRepository repository; +public class LinkServiceImpl extends BaseCRUDService implements LinkService { + private final UserRepository userRepository; private final LinkMapper mapper; private final ShortLinkGeneratorResolver linkGeneratorResolver; private final MetricCollector metricCollector; + @Autowired + public LinkServiceImpl(LinkRepository repository, + LinkMapper mapper, + ShortLinkGeneratorResolver linkGeneratorResolver, + MetricCollector metricCollector, + UserRepository userRepository) { + super(repository); + this.mapper = mapper; + this.linkGeneratorResolver = linkGeneratorResolver; + this.metricCollector = metricCollector; + this.userRepository = userRepository; + } + @Override @Transactional @@ -53,6 +69,11 @@ public Result create(final LinkInDTO dto) { link.setShortLink(shortLink); link.setStatus(LinkStatus.CREATED); + // TODO if anonymous + + if(getAuthUser().isPresent()) { + link.setUser(userRepository.getReferenceById(getAuthUser().get().getId())); + } link = repository.save(link); log.info("Generate new link {} to {} (id will be {}", link.getShortLink(), link.getOriginalLink(), link.getId()); @@ -67,16 +88,18 @@ public Result find(Long id) { if (link.isEmpty()) { return ResultFactory.notFound(); } + // TODO verify auth return ResultFactory.success(mapper.entityToDTO(link.get())); } @Override public Result find(final String shortLink) { - var link = repository.findByShortLink(shortLink); + var link = ((LinkRepository) repository).findByShortLink(shortLink); if (link.isEmpty()) { return ResultFactory.notFound(); } + // TODO verify auth return ResultFactory.success(mapper.entityToDTO(link.get())); } @@ -89,22 +112,31 @@ public Result deactivate(final Long id) { return ResultFactory.notFound(); } - var link = linkOpt.get(); - link.setStatus(LinkStatus.DELETED); - repository.save(link); - - return ResultFactory.success(); + return deactivate(linkOpt.get()); } @Override @Transactional public Result deactivate(final String shortLink) { - var linkOpt = repository.findByShortLink(shortLink); + var linkOpt = ((LinkRepository) repository).findByShortLink(shortLink); if (linkOpt.isEmpty()) { return ResultFactory.notFound(); } - var link = linkOpt.get(); + return deactivate(linkOpt.get()); + } + + private Result deactivate(Link link) { + Hibernate.initialize(link.getUser()); + + // Only admin can delete anonymous links + if (link.getUser() == null && !hasRight(UserRole.ADMIN)) { + throw new InsufficientAuthenticationException("1"); + } else if (getAuthUser().isPresent() && + !link.getUser().getId().equals(getAuthUser().get().getId())) { + throw new InsufficientAuthenticationException("2"); + } + link.setStatus(LinkStatus.DELETED); repository.save(link); diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java b/src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..d4f4ea6 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java @@ -0,0 +1,38 @@ +package dev.alnat.tinylinkshortener.service.impl; + +import dev.alnat.tinylinkshortener.model.User; +import dev.alnat.tinylinkshortener.repository.UserRepository; +import dev.alnat.tinylinkshortener.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * Created by @author AlNat on 28.01.2023. + * Licensed by Apache License, Version 2.0 + */ +@Slf4j +@Service +@Transactional(readOnly = true) +public class UserServiceImpl extends BaseCRUDService implements UserService { + + public UserServiceImpl(UserRepository repository) { + super(repository); + } + + @Override + public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException { + return ((UserRepository) repository) + .findUserByName(username) + .orElseThrow(() -> new UsernameNotFoundException("Not found user " + username)); + } + + @Override + public Optional findByKey(final String key) throws UsernameNotFoundException { + return ((UserRepository) repository).findByKey(key); + } +} diff --git a/src/main/java/dev/alnat/tinylinkshortener/service/impl/VisitServiceImpl.java b/src/main/java/dev/alnat/tinylinkshortener/service/impl/VisitServiceImpl.java index 949ac88..819ad49 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/impl/VisitServiceImpl.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/VisitServiceImpl.java @@ -10,17 +10,18 @@ import dev.alnat.tinylinkshortener.mapper.VisitMapper; import dev.alnat.tinylinkshortener.model.Link; import dev.alnat.tinylinkshortener.model.Visit; +import dev.alnat.tinylinkshortener.model.enums.UserRole; import dev.alnat.tinylinkshortener.model.enums.VisitStatus; import dev.alnat.tinylinkshortener.repository.LinkRepository; import dev.alnat.tinylinkshortener.repository.VisitRepository; import dev.alnat.tinylinkshortener.service.VisitService; import dev.alnat.tinylinkshortener.util.Utils; import io.hypersistence.utils.hibernate.type.basic.Inet; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.Hibernate; import org.springframework.http.HttpHeaders; import org.springframework.scheduling.annotation.Async; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.request.async.DeferredResult; @@ -37,15 +38,24 @@ */ @Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -public class VisitServiceImpl implements VisitService { +public class VisitServiceImpl extends BaseCRUDService implements VisitService { private final LinkRepository linkRepository; - private final VisitRepository repository; + private final VisitRepository visitRepository; private final VisitMapper mapper; private final LinkMapper linkMapper; + public VisitServiceImpl(LinkRepository linkRepository, + VisitRepository repository, + VisitMapper mapper, + LinkMapper linkMapper) { + super(repository); + this.linkRepository = linkRepository; + this.visitRepository = repository; + this.mapper = mapper; + this.linkMapper = linkMapper; + } @Override @Transactional @@ -75,7 +85,7 @@ public Optional findById(Long id) { @Override public List findByParams(final LinkVisitSearchRequest request) { - var visitList = repository.search(request); + var visitList = visitRepository.search(request); if (visitList.isEmpty()) { return Collections.emptyList(); } @@ -86,7 +96,7 @@ public List findByParams(final LinkVisitSearchRequest request) { @Override public LinkVisitPageResult searchRawStatistics(final LinkVisitSearchRequest request) { - var page = repository.search(request); + var page = visitRepository.search(request); var result = new LinkVisitPageResult(request); if (page.isEmpty()) { @@ -127,6 +137,14 @@ public Result searchAggregateStatistics(final String shortLi } var link = linkOpt.get(); + Hibernate.initialize(link.getUser()); + + // Only admin can see not its links stats + if (link.getUser() == null || (getAuthUser().isPresent() && + !link.getUser().getId().equals(getAuthUser().get().getId()))) { + throw new InsufficientAuthenticationException("2"); + } + Hibernate.initialize(link.getVisitList()); var stat = new LinkVisitStatistic(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c70bf06..6c5f266 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -86,6 +86,7 @@ springdoc: disabled: false custom: + is-anonymous-link-creation-enabled: ${IS_ANONYMOUS_LINK_CREATION_ENABLED:true} short-link-generator-mode: ${SHORT_LINK_GENERATOR:SEQUENCE} metrics: metric-prefix: ${METRIC_PREFIX:} diff --git a/src/main/resources/db/V03__add_users.sql b/src/main/resources/db/V03__add_users.sql new file mode 100644 index 0000000..b924bf6 --- /dev/null +++ b/src/main/resources/db/V03__add_users.sql @@ -0,0 +1,22 @@ +CREATE TABLE tiny_link_shortener.user( + id SERIAL4 PRIMARY KEY, + name varchar(256) NOT NULL, + key varchar(64), + role int4 NOT NULL, + is_active boolean NOT NULL default true +); + +-- TODO comments +-- TODO undex to key + +-- TODO table for roles description + +-- Modify links table + +ALTER TABLE tiny_link_shortener.link + ADD column user_id int4 REFERENCES tiny_link_shortener.user(id); + +comment on column tiny_link_shortener.link.user_id is 'User who creat the link'; + +-- Recommends do it concurently outsiide +CREATE INDEX IF NOT EXISTS link_user_id_idx ON tiny_link_shortener.link (user_id);