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);