Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/LWB-44_delete-user-account #66

Merged
merged 9 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading