From 2ee354cdda6997605a94bcc824a54dcc54a3ef6c Mon Sep 17 00:00:00 2001 From: AlNat Date: Mon, 30 Jan 2023 01:57:42 +0300 Subject: [PATCH 1/2] Security p1 Plus basic freamwork impl --- README.md | 7 ++ pom.xml | 5 + .../tinylinkshortener/config/Constants.java | 15 +++ .../config/SecurityConfig.java | 66 +++++++++++++ .../tinylinkshortener/dto/UserInDTO.java | 17 ++++ .../tinylinkshortener/dto/UserOutDTO.java | 33 +++++++ .../dto/common/ResultFactory.java | 9 +- .../exceptions/NotFoundException.java | 21 +++++ .../tinylinkshortener/mapper/UserMapper.java | 14 +++ .../tinylinkshortener/model/Activating.java | 15 +++ .../alnat/tinylinkshortener/model/Link.java | 7 +- .../alnat/tinylinkshortener/model/Model.java | 14 +++ .../alnat/tinylinkshortener/model/User.java | 94 +++++++++++++++++++ .../alnat/tinylinkshortener/model/Visit.java | 2 +- .../model/converter/UserRoleConverter.java | 26 +++++ .../model/enums/UserRole.java | 29 ++++++ .../repository/UserRepository.java | 17 ++++ .../security/HeaderKeySecurityFilter.java | 79 ++++++++++++++++ .../security/model/UserAuth.java | 42 +++++++++ .../service/BaseService.java | 31 +++++- .../service/UserService.java | 16 ++++ .../service/VisitService.java | 3 +- .../service/impl/BaseCRUDService.java | 89 ++++++++++++++++++ .../service/impl/LinkServiceImpl.java | 19 +++- .../service/impl/UserServiceImpl.java | 33 +++++++ .../service/impl/VisitServiceImpl.java | 20 ++-- src/main/resources/application.yml | 1 + src/main/resources/db/V03__add_users.sql | 22 +++++ 28 files changed, 731 insertions(+), 15 deletions(-) create mode 100644 src/main/java/dev/alnat/tinylinkshortener/config/Constants.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/dto/UserInDTO.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/dto/UserOutDTO.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/exceptions/NotFoundException.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/mapper/UserMapper.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/model/Activating.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/model/Model.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/model/User.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/model/converter/UserRoleConverter.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/model/enums/UserRole.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/repository/UserRepository.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/security/model/UserAuth.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/service/UserService.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java create mode 100644 src/main/resources/db/V03__add_users.sql diff --git a/README.md b/README.md index bac3bf1..1884de5 100644 --- a/README.md +++ b/README.md @@ -206,3 +206,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/pom.xml b/pom.xml index 50ad561..ed4f314 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,11 @@ flyway-core + + org.springframework.boot + spring-boot-starter-security + + org.springdoc springdoc-openapi-ui 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/SecurityConfig.java b/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java new file mode 100644 index 0000000..7c69498 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java @@ -0,0 +1,66 @@ +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.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(UserService service, PasswordEncoder encoder, + ObjectMapper mapper, UserMapper userMapper) { + return new HeaderKeySecurityFilter(service, NOT_AUTH_ENDPOINTS, encoder, mapper, userMapper); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, HeaderKeySecurityFilter filter) throws Exception { + return http + .addFilter(filter) + .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/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..7d431cf --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java @@ -0,0 +1,79 @@ +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.stereotype.Component; +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 +@Component +@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/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..0968936 --- /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 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 { + + 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..1916d7a 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,12 @@ 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.repository.LinkRepository; import dev.alnat.tinylinkshortener.service.LinkService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,15 +23,26 @@ */ @Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -public class LinkServiceImpl implements LinkService { +public class LinkServiceImpl extends BaseCRUDService implements LinkService { private final LinkRepository repository; private final LinkMapper mapper; private final ShortLinkGeneratorResolver linkGeneratorResolver; private final MetricCollector metricCollector; + @Autowired + public LinkServiceImpl(LinkRepository repository, + LinkMapper mapper, + ShortLinkGeneratorResolver linkGeneratorResolver, + MetricCollector metricCollector) { + super(repository); + this.repository = repository; + this.mapper = mapper; + this.linkGeneratorResolver = linkGeneratorResolver; + this.metricCollector = metricCollector; + } + @Override @Transactional @@ -52,6 +64,7 @@ public Result create(final LinkInDTO dto) { var link = mapper.inDTO(dto); link.setShortLink(shortLink); link.setStatus(LinkStatus.CREATED); + // TODO SetUser link = 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..cab8495 --- /dev/null +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java @@ -0,0 +1,33 @@ +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; + +/** + * 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(String username) throws UsernameNotFoundException { + return ((UserRepository) repository) + .findUserByName(username) + .orElseThrow(() -> new UsernameNotFoundException("Not found user " + username)); + } + +} 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..066ad1d 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/impl/VisitServiceImpl.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/VisitServiceImpl.java @@ -16,7 +16,6 @@ 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; @@ -37,15 +36,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 +83,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 +94,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()) { 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..b3d4764 --- /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'; + +CREATE INDEX CONCURRENTLY + IF NOT EXISTS link_user_id_idx ON tiny_link_shortener.link (user_id); From 55ac5f600c790a759e5abceeea481459646f1e78 Mon Sep 17 00:00:00 2001 From: AlNat Date: Wed, 1 Feb 2023 01:26:19 +0300 Subject: [PATCH 2/2] More security --- README.md | 6 +++ TODO.md | 14 +++--- pom.xml | 6 +++ .../config/OpenAPIConfiguration.java | 16 +++++++ .../config/SecurityConfig.java | 9 ++-- .../controller/LinkController.java | 5 +++ .../controller/ShortLinkController.java | 3 +- .../controller/UserController.java | 11 +++++ .../controller/VisitController.java | 2 + .../handler/GlobalExceptionHandler.java | 10 +++++ .../security/HeaderKeySecurityFilter.java | 2 - .../service/SecurityContextRetriever.java | 26 +++++++++++ .../service/impl/BaseCRUDService.java | 4 +- .../service/impl/LinkServiceImpl.java | 45 +++++++++++++------ .../service/impl/UserServiceImpl.java | 9 +++- .../service/impl/VisitServiceImpl.java | 10 +++++ src/main/resources/db/V03__add_users.sql | 4 +- 17 files changed, 152 insertions(+), 30 deletions(-) create mode 100644 src/main/java/dev/alnat/tinylinkshortener/controller/UserController.java create mode 100644 src/main/java/dev/alnat/tinylinkshortener/service/SecurityContextRetriever.java diff --git a/README.md b/README.md index 1884de5..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 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 ed4f314..bd88eec 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,12 @@ 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/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 index 7c69498..9b24c1c 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java +++ b/src/main/java/dev/alnat/tinylinkshortener/config/SecurityConfig.java @@ -14,6 +14,7 @@ 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; @@ -44,15 +45,17 @@ public PasswordEncoder passwordEncoder() { } @Bean - public HeaderKeySecurityFilter securityFilter(UserService service, PasswordEncoder encoder, - ObjectMapper mapper, UserMapper userMapper) { + 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 - .addFilter(filter) + .addFilterBefore(filter, BasicAuthenticationFilter.class) .requestCache().requestCache(new NullRequestCache()) // Disable caching .and() .csrf().disable() 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/security/HeaderKeySecurityFilter.java b/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java index 7d431cf..2fd2123 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java +++ b/src/main/java/dev/alnat/tinylinkshortener/security/HeaderKeySecurityFilter.java @@ -12,7 +12,6 @@ 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.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -30,7 +29,6 @@ * Licensed by Apache License, Version 2.0 */ @Slf4j -@Component @RequiredArgsConstructor public class HeaderKeySecurityFilter extends OncePerRequestFilter { 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/impl/BaseCRUDService.java b/src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java index 0968936..2d678c1 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/BaseCRUDService.java @@ -4,6 +4,7 @@ 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; @@ -21,11 +22,10 @@ */ @RequiredArgsConstructor @Transactional -public abstract class BaseCRUDService, ID> implements BaseService { +public abstract class BaseCRUDService, ID> implements BaseService, SecurityContextRetriever { protected final JpaRepository repository; - @Override public E create(E entity) { return repository.save(entity); 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 1916d7a..66a27d6 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/impl/LinkServiceImpl.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/LinkServiceImpl.java @@ -10,10 +10,14 @@ 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.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; @@ -25,8 +29,7 @@ @Service @Transactional(readOnly = true) public class LinkServiceImpl extends BaseCRUDService implements LinkService { - - private final LinkRepository repository; + private final UserRepository userRepository; private final LinkMapper mapper; private final ShortLinkGeneratorResolver linkGeneratorResolver; private final MetricCollector metricCollector; @@ -35,12 +38,13 @@ public class LinkServiceImpl extends BaseCRUDService implements Link public LinkServiceImpl(LinkRepository repository, LinkMapper mapper, ShortLinkGeneratorResolver linkGeneratorResolver, - MetricCollector metricCollector) { + MetricCollector metricCollector, + UserRepository userRepository) { super(repository); - this.repository = repository; this.mapper = mapper; this.linkGeneratorResolver = linkGeneratorResolver; this.metricCollector = metricCollector; + this.userRepository = userRepository; } @@ -64,8 +68,12 @@ public Result create(final LinkInDTO dto) { var link = mapper.inDTO(dto); link.setShortLink(shortLink); link.setStatus(LinkStatus.CREATED); - // TODO SetUser + // 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()); @@ -80,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())); } @@ -102,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 index cab8495..d4f4ea6 100644 --- a/src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java +++ b/src/main/java/dev/alnat/tinylinkshortener/service/impl/UserServiceImpl.java @@ -9,6 +9,8 @@ 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 @@ -18,16 +20,19 @@ @Transactional(readOnly = true) public class UserServiceImpl extends BaseCRUDService implements UserService { - public UserServiceImpl(UserRepository repository) { super(repository); } @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + 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 066ad1d..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,6 +10,7 @@ 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; @@ -20,6 +21,7 @@ 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; @@ -135,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/db/V03__add_users.sql b/src/main/resources/db/V03__add_users.sql index b3d4764..b924bf6 100644 --- a/src/main/resources/db/V03__add_users.sql +++ b/src/main/resources/db/V03__add_users.sql @@ -18,5 +18,5 @@ ALTER TABLE tiny_link_shortener.link comment on column tiny_link_shortener.link.user_id is 'User who creat the link'; -CREATE INDEX CONCURRENTLY - IF NOT EXISTS link_user_id_idx ON tiny_link_shortener.link (user_id); +-- Recommends do it concurently outsiide +CREATE INDEX IF NOT EXISTS link_user_id_idx ON tiny_link_shortener.link (user_id);