Skip to content

Commit

Permalink
Merge pull request #66 from L1nkWave/feature/LWB-44_delete-user-account
Browse files Browse the repository at this point in the history
feature/LWB-44_delete-user-account
  • Loading branch information
borjom1 committed May 9, 2024
2 parents 5fbc0ac + 74c4e33 commit 4d4ddd0
Show file tree
Hide file tree
Showing 26 changed files with 578 additions and 110 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.linkwave.auth.dto;

public record UserDeleteRequest(String password) {
}
Original file line number Diff line number Diff line change
@@ -1,36 +1,57 @@
package org.linkwave.auth.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;

import java.time.ZonedDateTime;
import java.util.LinkedList;
import java.util.List;

import static java.time.ZonedDateTime.now;

@Entity
@Table(name = "users")
@Table(
name = "users",
indexes = @Index(columnList = "username", unique = true)
)
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true)
@Column(unique = true, length = 32)
private String username;

private String password;

@ColumnDefault("false")
private boolean isDeleted;

@ColumnDefault("false")
private boolean isBlocked;

@ColumnDefault("false")
@Column(nullable = false)
private boolean isOnline;

@Column(nullable = false, columnDefinition = "TIMESTAMP WITH TIME ZONE default now()")
@Builder.Default
private ZonedDateTime lastSeen = now().plusSeconds(1L);

@ManyToMany
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
uniqueConstraints = @UniqueConstraint(
name = "UC_rd_uid",
columnNames = {"roles_id", "users_id"}
)
)
@Builder.Default
private List<Role> roles = new LinkedList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.linkwave.auth.exception;

import org.springframework.security.core.AuthenticationException;

public class UserNotFoundException extends AuthenticationException {
public UserNotFoundException() {
super("User not found");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.UUID;

@Repository
public interface DeactivatedTokenRepository extends JpaRepository<DeactivatedToken, UUID> {

@Modifying
@Query(
value = "delete from deactivated_tokens where expiration < now()",
value = "delete from deactivated_tokens where expiration < :timeAgo",
nativeQuery = true
)
void removeAllExpiredTokens();
void removeAllExpiredTokens(Instant timeAgo);

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@
public class DefaultUserDetails extends User {

private final Long id;
private final boolean isDeleted;
private final boolean isBlocked;

public DefaultUserDetails(Long id, String username, String password,
public DefaultUserDetails(Long id, String username, String password, boolean isDeleted, boolean isBlocked,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.id = id;
this.isDeleted = isDeleted;
this.isBlocked = isBlocked;
}

@Override
public boolean isEnabled() {
return !isDeleted && !isBlocked;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
user.getId(),
user.getUsername(),
user.getPassword(),
user.isDeleted(),
user.isBlocked(),
user.getRoles().stream()
.map(Role::getName)
.map(SimpleGrantedAuthority::new)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.linkwave.auth.repository.DeactivatedTokenRepository;
import org.linkwave.auth.security.filter.JwtDeleteUserFilter;
import org.linkwave.auth.security.filter.JwtLogoutFilter;
import org.linkwave.auth.security.filter.JwtTokensInitializerFilter;
import org.linkwave.auth.security.filter.JwtTokensRefreshFilter;
import org.linkwave.auth.service.UserService;
import org.linkwave.shared.auth.JwtAccessParser;
import org.linkwave.shared.auth.JwtAccessSerializer;
import org.linkwave.auth.security.jwt.JwtRefreshParser;
Expand All @@ -33,6 +35,7 @@ public class JwtAuthFiltersConfigurer extends AbstractHttpConfigurer<JwtAuthFilt
private JwtAccessSerializer jwtAccessSerializer;
private JwtRefreshParser jwtRefreshParser;
private JwtAccessParser jwtAccessParser;
private UserService userService;

@Override
public void configure(@NonNull HttpSecurity builder) {
Expand All @@ -52,10 +55,13 @@ public void configure(@NonNull HttpSecurity builder) {

final var jwtLogoutFilter = new JwtLogoutFilter(objectMapper, jwtAccessParser, tokenRepository);

final var jwtDeleteUserFilter = new JwtDeleteUserFilter(objectMapper, jwtAccessParser, userService);

// add filters to filter chain
builder.addFilterAfter(jwtTokensInitializerFilter, ExceptionTranslationFilter.class);
builder.addFilterAfter(jwtTokensRefreshFilter, ExceptionTranslationFilter.class);
builder.addFilterAfter(jwtLogoutFilter, ExceptionTranslationFilter.class);
builder.addFilterBefore(jwtTokensInitializerFilter, ExceptionTranslationFilter.class);
builder.addFilterBefore(jwtTokensRefreshFilter, ExceptionTranslationFilter.class);
builder.addFilterBefore(jwtLogoutFilter, ExceptionTranslationFilter.class);
builder.addFilterBefore(jwtDeleteUserFilter, ExceptionTranslationFilter.class);
}

/*
Expand Down Expand Up @@ -88,4 +94,9 @@ public JwtAuthFiltersConfigurer setJwtAccessParser(JwtAccessParser jwtAccessPars
this.jwtAccessParser = jwtAccessParser;
return this;
}

public JwtAuthFiltersConfigurer setUserService(UserService userService) {
this.userService = userService;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.linkwave.auth.service.UserService;
import org.linkwave.shared.auth.JwtAccessParser;
import org.linkwave.shared.auth.JwtAccessSerializer;
import org.linkwave.auth.security.jwt.JwtRefreshParser;
Expand Down Expand Up @@ -53,7 +54,8 @@ public SecurityFilterChain filterChain(@NonNull HttpSecurity httpSecurity,
JwtRefreshSerializer refreshSerializer,
JwtAccessSerializer accessSerializer,
JwtRefreshParser refreshParser,
JwtAccessParser accessParser) throws Exception {
JwtAccessParser accessParser,
UserService userService) throws Exception {
httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
Expand All @@ -73,7 +75,8 @@ public SecurityFilterChain filterChain(@NonNull HttpSecurity httpSecurity,
.setJwtRefreshSerializer(refreshSerializer)
.setJwtAccessSerializer(accessSerializer)
.setJwtRefreshParser(refreshParser)
.setJwtAccessParser(accessParser);
.setJwtAccessParser(accessParser)
.setUserService(userService);


final var filterChain = httpSecurity.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.linkwave.auth.security.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.linkwave.auth.dto.UserDeleteRequest;
import org.linkwave.auth.security.UnAuthorizedAuthenticationEntryPoint;
import org.linkwave.auth.service.UserService;
import org.linkwave.shared.auth.JwtAccessParser;
import org.linkwave.shared.auth.Token;
import org.linkwave.shared.utils.Bearers;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtDeleteUserFilter extends OncePerRequestFilter {

private final RequestMatcher matcher = new AntPathRequestMatcher("/api/v1/users", HttpMethod.DELETE.name());

private final ObjectMapper objectMapper;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final JwtAccessParser jwtAccessParser;
private final UserService userService;

public JwtDeleteUserFilter(ObjectMapper objectMapper, JwtAccessParser jwtAccessParser, UserService userService) {
this.objectMapper = objectMapper;
this.jwtAccessParser = jwtAccessParser;
this.userService = userService;
this.authenticationEntryPoint = new UnAuthorizedAuthenticationEntryPoint(objectMapper);
}

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
if (!matcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}

final String header = request.getHeader(HttpHeaders.AUTHORIZATION);

try {
final String password;
try {
final UserDeleteRequest deleteRequest = objectMapper.readValue(request.getInputStream(), UserDeleteRequest.class);
password = deleteRequest.password();
} catch (IOException e) {
throw new BadCredentialsException("Invalid request body");
}

if (header == null || !header.startsWith(Bearers.BEARER_PREFIX)) {
throw new BadCredentialsException("Bearer is not present");
}

final Token accessToken = jwtAccessParser.parse(header.substring(Bearers.TOKEN_START_POSITION));
userService.deleteAccount(accessToken, password);

response.setContentLength(0);
response.setStatus(HttpServletResponse.SC_NO_CONTENT);

} catch (AuthenticationException e) {
authenticationEntryPoint.commence(request, response, e);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,27 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.concurrent.TimeUnit;

import static java.time.temporal.ChronoUnit.HOURS;

@Slf4j
@Service
@RequiredArgsConstructor
public class TokenCleaner {

private final DeactivatedTokenRepository tokenRepository;

/**
* Cleans all added tokens to database in specified interval.
*/
@Transactional
@Scheduled(timeUnit = TimeUnit.MINUTES, fixedRate = 10)
@Scheduled(timeUnit = TimeUnit.MINUTES, fixedRate = 30)
public void clean() {
log.debug("-> clean()");
tokenRepository.removeAllExpiredTokens();
final Instant hourAgo = Instant.now().minus(1L, HOURS);
tokenRepository.removeAllExpiredTokens(hourAgo);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.linkwave.auth.service;

import lombok.RequiredArgsConstructor;
import org.linkwave.auth.entity.DeactivatedToken;
import org.linkwave.auth.entity.User;
import org.linkwave.auth.exception.UserNotFoundException;
import org.linkwave.auth.repository.DeactivatedTokenRepository;
import org.linkwave.auth.repository.UserRepository;
import org.linkwave.shared.auth.Token;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.ZonedDateTime;

@Service
@RequiredArgsConstructor
public class UserService {

private final DeactivatedTokenRepository deactivatedTokenRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;

@Transactional
public void deleteAccount(Token accessToken, String userPassword) {
if (accessToken == null || deactivatedTokenRepository.existsById(accessToken.id())) {
throw new CredentialsExpiredException("Access token is unavailable");
}

final User user = userRepository.findById(accessToken.userId()).orElseThrow(UserNotFoundException::new);
if (!passwordEncoder.matches(userPassword, user.getPassword())) {
throw new BadCredentialsException("Credentials not valid");
}

final var deactivatedToken = new DeactivatedToken(accessToken.id(), accessToken.expireAt());
deactivatedTokenRepository.save(deactivatedToken);

user.setDeleted(true);
user.setUsername(null);
user.setOnline(false);
user.setLastSeen(ZonedDateTime.now());
}

}
3 changes: 2 additions & 1 deletion backend/auth-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ spring:
driver-class-name: org.postgresql.Driver

jpa:
open-in-view: false
properties:
hibernate:
show_sql: false
format_sql: true
highlight_sql: true

hibernate:
ddl-auto: none
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy

Expand Down
Loading

0 comments on commit 4d4ddd0

Please sign in to comment.